Files
afilmory/packages/builder/src/photo/image-pipeline.ts
Innei 76debaa386 feat(photo): implement photo tag management and update functionality
- Added UpdatePhotoTagsDto for validating photo tag updates.
- Implemented updateAssetTags method in PhotoAssetService to handle tag updates, including validation and error handling.
- Enhanced PhotoController with a new endpoint for updating photo tags.
- Introduced PhotoTagEditorModal for editing tags in the UI, allowing batch updates for selected photos.
- Updated PhotoLibrary components to support tag editing and display changes in the UI.

Signed-off-by: Innei <tukon479@gmail.com>
2025-11-17 15:01:56 +08:00

342 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto'
import path from 'node:path'
import { compressUint8Array } from '@afilmory/utils'
import sharp from 'sharp'
import type { BuilderOptions } from '../builder/builder.js'
import {
convertBmpToJpegSharpInstance,
getImageMetadataWithSharp,
isBitmap,
preprocessImageBuffer,
} from '../image/processor.js'
import type { PluginRunState } from '../plugins/manager.js'
import { THUMBNAIL_PLUGIN_DATA_KEY } from '../plugins/thumbnail-storage/shared.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import type { S3ObjectLike } from '../types/s3.js'
import { shouldProcessPhoto } from './cache-manager.js'
import { processExifData, processThumbnailAndBlurhash, processToneAnalysis } from './data-processors.js'
import { getPhotoExecutionContext } from './execution-context.js'
import { detectGainMap } from './gainmap-detector.js'
import { extractPhotoInfo } from './info-extractor.js'
import { processLivePhoto } from './live-photo-handler.js'
import { getGlobalLoggers } from './logger-adapter.js'
import { detectMotionPhoto } from './motion-photo-detector.js'
import type { PhotoProcessorOptions } from './processor.js'
export interface ProcessedImageData {
sharpInstance: sharp.Sharp
imageBuffer: Buffer
metadata: { width: number; height: number }
}
export interface PhotoProcessingContext {
photoKey: string
obj: S3ObjectLike
existingItem: PhotoManifestItem | undefined
livePhotoMap: Map<string, S3ObjectLike>
options: PhotoProcessorOptions
pluginData: Record<string, unknown>
}
/**
* 预处理图片数据
* 包括获取原始数据、格式转换、BMP 处理等
*/
export async function preprocessImage(
photoKey: string,
): Promise<{ rawBuffer: Buffer; processedBuffer: Buffer } | null> {
const loggers = getGlobalLoggers()
const { storageManager } = getPhotoExecutionContext()
try {
// 获取图片数据
const rawImageBuffer = await storageManager.getFile(photoKey)
if (!rawImageBuffer) {
loggers.image.error(`无法获取图片数据:${photoKey}`)
return null
}
// 预处理图片(处理 HEIC/HEIF 格式)
let imageBuffer: Buffer
try {
imageBuffer = await preprocessImageBuffer(rawImageBuffer, photoKey)
} catch (error) {
loggers.image.error(`预处理图片失败:${photoKey}`, error)
return null
}
return {
rawBuffer: rawImageBuffer,
processedBuffer: imageBuffer,
}
} catch (error) {
loggers.image.error(`图片预处理失败:${photoKey}`, error)
return null
}
}
/**
* 处理图片并创建 Sharp 实例
* 包括 BMP 转换和元数据提取
*/
export async function processImageWithSharp(imageBuffer: Buffer, photoKey: string): Promise<ProcessedImageData | null> {
const loggers = getGlobalLoggers()
try {
// 创建 Sharp 实例,复用于多个操作
let sharpInstance = sharp(imageBuffer)
let processedBuffer = imageBuffer
// 处理 BMP
if (isBitmap(imageBuffer)) {
try {
// Convert the BMP image to JPEG format and create a new Sharp instance for the converted image.
sharpInstance = await convertBmpToJpegSharpInstance(imageBuffer)
// Update the image buffer to reflect the new JPEG data from the Sharp instance.
processedBuffer = await sharpInstance.toBuffer()
} catch (error) {
loggers.image.error(`转换 BMP 失败:${photoKey}`, error)
return null
}
}
// 获取图片元数据(复用 Sharp 实例)
const metadata = await getImageMetadataWithSharp(sharpInstance)
if (!metadata) {
loggers.image.error(`获取图片元数据失败:${photoKey}`)
return null
}
return {
sharpInstance,
imageBuffer: processedBuffer,
metadata,
}
} catch (error) {
loggers.image.error(`Sharp 处理失败:${photoKey}`, error)
return null
}
}
/**
* 生成带摘要后缀的 ID
* @param s3Key S3 键
* @returns 带摘要后缀的 ID
*/
async function generatePhotoId(s3Key: string): Promise<string> {
const { builder } = getPhotoExecutionContext()
const {
system: {
processing: { digestSuffixLength },
},
} = builder.getConfig()
if (!digestSuffixLength || digestSuffixLength <= 0) {
return path.basename(s3Key, path.extname(s3Key))
}
const baseName = path.basename(s3Key, path.extname(s3Key))
const sha256 = crypto.createHash('sha256').update(s3Key).digest('hex')
const digestSuffix = sha256.slice(0, digestSuffixLength)
return `${baseName}_${digestSuffix}`
}
/**
* 完整的照片处理管道
* 整合所有处理步骤
*/
export async function executePhotoProcessingPipeline(
context: PhotoProcessingContext,
): Promise<PhotoManifestItem | null> {
const { photoKey, obj, existingItem, livePhotoMap, options } = context
const { storageManager } = getPhotoExecutionContext()
const loggers = getGlobalLoggers()
// Generate the actual photo ID with digest suffix
const photoId = await generatePhotoId(photoKey)
try {
// 1. 预处理图片
const imageData = await preprocessImage(photoKey)
if (!imageData) return null
// 2. 处理图片并创建 Sharp 实例
const processedData = await processImageWithSharp(imageData.processedBuffer, photoKey)
if (!processedData) return null
const { sharpInstance, imageBuffer, metadata } = processedData
const contentDigest = crypto.createHash('sha256').update(imageBuffer).digest('hex')
// 3. 处理缩略图和 blurhash
const thumbnailResult = await processThumbnailAndBlurhash(imageBuffer, photoId, existingItem, options)
context.pluginData[THUMBNAIL_PLUGIN_DATA_KEY] = {
photoId,
fileName: `${photoId}.jpg`,
buffer: thumbnailResult.thumbnailBuffer,
localUrl: thumbnailResult.thumbnailUrl,
}
// 4. 处理 EXIF 数据
const exifData = await processExifData(imageBuffer, imageData.rawBuffer, photoKey, existingItem, options)
// 5. 检测 HDR GainMapUltra HDR 图片)
const hasGainMap = detectGainMap({
exifData: exifData as Record<string, unknown> | null,
})
// 6. 检测 Motion Photo从图片中提取嵌入视频的元数据
const motionPhotoMetadata = detectMotionPhoto({
rawImageBuffer: imageData.rawBuffer,
exifData: exifData as Record<string, unknown> | null,
})
// 7. 处理 Live Photo独立的视频文件
const livePhotoResult = await processLivePhoto(photoKey, livePhotoMap, storageManager)
// 检测冲突:不允许同时存在 Motion Photo 和 Live Photo
if (motionPhotoMetadata?.isMotionPhoto && livePhotoResult.isLivePhoto) {
const errorMsg = `❌ 检测到同时存在 Motion Photo (嵌入视频) 和 Live Photo (独立视频文件)${photoKey}。这是不允许的,请只保留一种格式。`
loggers.image.error(errorMsg)
throw new Error(errorMsg)
}
// 7. 处理影调分析
const toneAnalysis = await processToneAnalysis(sharpInstance, photoKey, existingItem, options)
// 8. 提取照片信息
const photoInfo = extractPhotoInfo(photoKey, exifData)
// 9. 构建照片清单项
const aspectRatio = metadata.width / metadata.height
const photoItem: PhotoManifestItem = {
id: photoId,
title: photoInfo.title,
description: photoInfo.description,
dateTaken: photoInfo.dateTaken,
tags: photoInfo.tags,
originalUrl: await storageManager.generatePublicUrl(photoKey),
thumbnailUrl: thumbnailResult.thumbnailUrl,
thumbHash: thumbnailResult.thumbHash ? compressUint8Array(thumbnailResult.thumbHash) : null,
width: metadata.width,
height: metadata.height,
aspectRatio,
s3Key: photoKey,
lastModified: obj.LastModified?.toISOString() || new Date().toISOString(),
size: obj.Size || 0,
digest: contentDigest,
exif: exifData,
toneAnalysis,
// Video source (Motion Photo or Live Photo)
video:
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
? {
type: 'motion-photo',
offset: motionPhotoMetadata.motionPhotoOffset,
size: motionPhotoMetadata.motionPhotoVideoSize,
presentationTimestamp: motionPhotoMetadata.presentationTimestampUs,
}
: livePhotoResult.isLivePhoto
? {
type: 'live-photo',
videoUrl: livePhotoResult.livePhotoVideoUrl!,
s3Key: livePhotoResult.livePhotoVideoS3Key!,
}
: undefined,
// HDR 相关字段
isHDR:
exifData?.MPImageType === 'Gain Map Image' ||
exifData?.UniformResourceName === 'urn:iso:std:iso:ts:21496:-1' ||
hasGainMap,
}
loggers.image.success(`✅ 处理完成:${photoKey}`)
return photoItem
} catch (error) {
loggers.image.error(`❌ 处理管道失败:${photoKey}`, error)
return null
}
}
/**
* 决定是否需要处理照片并返回处理结果
*/
export async function processPhotoWithPipeline(
context: PhotoProcessingContext,
runtime: { runState: PluginRunState; builderOptions: BuilderOptions },
): Promise<{
item: PhotoManifestItem | null
type: 'new' | 'processed' | 'skipped' | 'failed'
pluginData: Record<string, unknown>
}> {
const { photoKey, existingItem, obj, options } = context
const { builder } = getPhotoExecutionContext()
const loggers = getGlobalLoggers()
const photoId = await generatePhotoId(photoKey)
await builder.emitPluginEvent(runtime.runState, 'beforePhotoProcess', {
options: runtime.builderOptions,
context,
})
// 检查是否需要处理
const { shouldProcess, reason } = await shouldProcessPhoto(photoId, existingItem, obj, options)
if (!shouldProcess) {
loggers.image.info(`⏭️ 跳过处理 (${reason}): ${photoKey}`)
const result = {
item: existingItem ?? null,
type: 'skipped' as const,
pluginData: context.pluginData,
}
await builder.emitPluginEvent(runtime.runState, 'afterPhotoProcess', {
options: runtime.builderOptions,
context,
result,
})
return result
}
// 记录处理原因
const isNewPhoto = !existingItem
if (isNewPhoto) {
loggers.image.info(`🆕 新照片:${photoKey}`)
} else {
loggers.image.info(`🔄 更新照片 (${reason})${photoKey}`)
}
let processedItem: PhotoManifestItem | null = null
let resultType: ProcessPhotoResult['type'] = isNewPhoto ? 'new' : 'processed'
try {
processedItem = await executePhotoProcessingPipeline(context)
if (!processedItem) {
resultType = 'failed'
}
} catch (error) {
await builder.emitPluginEvent(runtime.runState, 'photoProcessError', {
options: runtime.builderOptions,
context,
error,
})
loggers.image.error(`❌ 处理过程中发生异常:${photoKey}`, error)
processedItem = null
resultType = 'failed'
}
const result = {
item: processedItem,
type: resultType,
pluginData: context.pluginData,
}
await builder.emitPluginEvent(runtime.runState, 'afterPhotoProcess', {
options: runtime.builderOptions,
context,
result,
})
return result
}