mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
- 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:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
147
packages/builder/src/photo/data-processors.ts
Normal file
147
packages/builder/src/photo/data-processors.ts
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user