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> { ): Promise<string | null> {
try { try {
// 复用缩略图的 Sharp 实例来生成 blurhash // 复用缩略图的 Sharp 实例来生成 blurhash
const { data, info } = await sharp(thumbnailBuffer).toBuffer({ // 确保转换为 raw RGBA 格式
resolveWithObject: true, 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 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) 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}`, `生成参数:原始 ${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 // 生成 blurhash
const blurhash = encode( const blurhash = encode(
new Uint8ClampedArray(data), new Uint8ClampedArray(data),

View File

@@ -4,18 +4,55 @@ import path from 'node:path'
import { workdir } from '@afilmory/builder/path.js' import { workdir } from '@afilmory/builder/path.js'
import sharp from 'sharp' 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 type { ThumbnailResult } from '../types/photo.js'
import { generateBlurhash } from './blurhash.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> { export async function thumbnailExists(photoId: string): Promise<boolean> {
try { try {
const thumbnailPath = path.join( const { thumbnailPath } = getThumbnailPaths(photoId)
workdir,
'public/thumbnails',
`${photoId}.webp`,
)
await fs.access(thumbnailPath) await fs.access(thumbnailPath)
return true return true
} catch { } 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 实例) // 生成缩略图和 blurhash复用 Sharp 实例)
export async function generateThumbnailAndBlurhash( export async function generateThumbnailAndBlurhash(
imageBuffer: Buffer, imageBuffer: Buffer,
@@ -30,86 +143,35 @@ export async function generateThumbnailAndBlurhash(
originalWidth: number, originalWidth: number,
originalHeight: number, originalHeight: number,
forceRegenerate = false, forceRegenerate = false,
workerLogger?: {
thumbnail: Logger['thumbnail']
blurhash: Logger['blurhash']
},
): Promise<ThumbnailResult> { ): Promise<ThumbnailResult> {
const thumbnailLog = workerLogger?.thumbnail const thumbnailLog = getGlobalLoggers().thumbnail
try { try {
const thumbnailDir = path.join(workdir, 'public/thumbnails') await ensureThumbnailDir()
await fs.mkdir(thumbnailDir, { recursive: true })
const thumbnailPath = path.join(thumbnailDir, `${photoId}.webp`) // 如果不是强制模式且缩略图已存在,尝试复用现有文件
const thumbnailUrl = `/thumbnails/${photoId}.webp`
// 如果不是强制模式且缩略图已存在,读取现有文件
if (!forceRegenerate && (await thumbnailExists(photoId))) { if (!forceRegenerate && (await thumbnailExists(photoId))) {
thumbnailLog?.info(`复用现有缩略图:${photoId}`) const existingResult = await processExistingThumbnail(
try { photoId,
const existingBuffer = await fs.readFile(thumbnailPath) originalWidth,
originalHeight,
)
// 基于现有缩略图生成 blurhash if (existingResult) {
const blurhash = await generateBlurhash( return existingResult
existingBuffer,
originalWidth,
originalHeight,
)
return {
thumbnailUrl,
thumbnailBuffer: existingBuffer,
blurhash,
}
} catch (error) {
thumbnailLog?.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
// 继续执行生成逻辑
} }
// 如果处理现有缩略图失败,继续生成新的
} }
thumbnailLog?.info(`生成缩略图:${photoId}`) // 生成新的缩略图
const startTime = Date.now() return await generateNewThumbnail(
imageBuffer,
// 创建 Sharp 实例,复用于缩略图和 blurhash 生成 photoId,
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,
originalWidth, originalWidth,
originalHeight, originalHeight,
) )
return {
thumbnailUrl,
thumbnailBuffer,
blurhash,
}
} catch (error) { } catch (error) {
thumbnailLog?.error(`生成失败:${photoId}`, error) thumbnailLog.error(`处理失败:${photoId}`, error)
return { return createFailureResult()
thumbnailUrl: null,
thumbnailBuffer: null,
blurhash: null,
}
} }
} }

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 { export type {
CompressedHistogramData, FujiRecipe,
HistogramData,
ImageMetadata,
PhotoInfo,
PhotoManifestItem, PhotoManifestItem,
ProcessPhotoResult, PickedExif,
ThumbnailResult,
ToneAnalysis, ToneAnalysis,
ToneType,
} from './types/photo.js' } 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 path from 'node:path'
import { workdir } from '@afilmory/builder/path.js' import { thumbnailExists } from '../image/thumbnail.js'
import type sharp from 'sharp' import type { PhotoManifestItem } from '../types/photo.js'
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 type { PhotoProcessorOptions } from './processor.js' import type { PhotoProcessorOptions } from './processor.js'
export interface ThumbnailResult {
thumbnailUrl: string
thumbnailBuffer: Buffer
blurhash: string
}
export interface CacheableData { export interface CacheableData {
thumbnail?: ThumbnailResult thumbnail?: {
exif?: PickedExif thumbnailUrl: string
toneAnalysis?: ToneAnalysis 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 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)
// 继续执行生成逻辑
}
} }
exif?: any
// 生成新的缩略图和 blurhash toneAnalysis?: any
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,
)
} }
/** /**
@@ -196,3 +58,36 @@ export async function shouldProcessPhoto(
return { shouldProcess: false, reason: '无需处理' } 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, preprocessImageBuffer,
} from '../image/processor.js' } from '../image/processor.js'
import type { PhotoManifestItem } from '../types/photo.js' import type { PhotoManifestItem } from '../types/photo.js'
import { shouldProcessPhoto } from './cache-manager.js'
import { import {
processExifData, processExifData,
processThumbnailAndBlurhash, processThumbnailAndBlurhash,
processToneAnalysis, processToneAnalysis,
shouldProcessPhoto, } from './data-processors.js'
} from './cache-manager.js'
import { extractPhotoInfo } from './info-extractor.js' import { extractPhotoInfo } from './info-extractor.js'
import { processLivePhoto } from './live-photo-handler.js' import { processLivePhoto } from './live-photo-handler.js'
import { getGlobalLoggers } from './logger-adapter.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 { 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 { export {
processExifData, processExifData,
processThumbnailAndBlurhash, processThumbnailAndBlurhash,
processToneAnalysis, processToneAnalysis,
shouldProcessPhoto, } from './data-processors.js'
} from './cache-manager.js'
// Live Photo 处理 // Live Photo 处理
export type { LivePhotoResult } from './live-photo-handler.js' export type { LivePhotoResult } from './live-photo-handler.js'