diff --git a/be/apps/core/src/modules/photo/photo.service.ts b/be/apps/core/src/modules/photo/photo.service.ts index af490316..95f64ca3 100644 --- a/be/apps/core/src/modules/photo/photo.service.ts +++ b/be/apps/core/src/modules/photo/photo.service.ts @@ -7,7 +7,7 @@ import type { StorageConfig, StorageObject, } from '@afilmory/builder' -import { AfilmoryBuilder, processPhotoWithPipeline } from '@afilmory/builder' +import { AfilmoryBuilder, processPhotoWithPipeline, thumbnailStoragePlugin } from '@afilmory/builder' import type { Logger as BuilderLogger } from '@afilmory/builder/logger/index.js' import type { PhotoProcessingLoggers } from '@afilmory/builder/photo/index.js' import { createPhotoProcessingLoggers, setGlobalLoggers } from '@afilmory/builder/photo/index.js' @@ -38,7 +38,8 @@ export class PhotoBuilderService { private photoLoggers: PhotoProcessingLoggers | null = null createBuilder(config: BuilderConfig): AfilmoryBuilder { - return new AfilmoryBuilder(config) + const enhancedConfig = this.ensureThumbnailPlugin(config) + return new AfilmoryBuilder(enhancedConfig) } applyStorageConfig(builder: AfilmoryBuilder, config: StorageConfig): void { @@ -139,4 +140,27 @@ export class PhotoBuilderService { return result } + + private ensureThumbnailPlugin(config: BuilderConfig): BuilderConfig { + const existingPlugins = config.plugins ?? [] + const hasPlugin = existingPlugins.some((entry) => { + if (typeof entry === 'string') { + return entry.includes('thumbnail-storage') + } + if (typeof entry === 'function') { + const fnName = entry.name ?? '' + return fnName.includes('thumbnailStorage') || entry.toString().includes('thumbnail-storage') + } + return entry?.name === 'afilmory:thumbnail-storage' + }) + + if (hasPlugin) { + return config + } + + return { + ...config, + plugins: [...existingPlugins, thumbnailStoragePlugin()], + } + } } diff --git a/builder.config.default.ts b/builder.config.default.ts index 55ce03a5..86b326c8 100644 --- a/builder.config.default.ts +++ b/builder.config.default.ts @@ -1,6 +1,6 @@ import os from 'node:os' -import { defineBuilderConfig } from '@afilmory/builder' +import { defineBuilderConfig, thumbnailStoragePlugin } from '@afilmory/builder' import { env } from './env.js' @@ -52,5 +52,5 @@ export default defineBuilderConfig(() => ({ workerConcurrency: 2, }, }, - plugins: [], + plugins: [thumbnailStoragePlugin()], })) diff --git a/packages/builder/src/config/defaults.ts b/packages/builder/src/config/defaults.ts index c244c326..b7d962fa 100644 --- a/packages/builder/src/config/defaults.ts +++ b/packages/builder/src/config/defaults.ts @@ -1,5 +1,6 @@ import os from 'node:os' +import thumbnailStoragePlugin from '../plugins/thumbnail-storage/index.js' import type { BuilderConfig } from '../types/config.js' export function createDefaultBuilderConfig(): BuilderConfig { @@ -51,6 +52,6 @@ export function createDefaultBuilderConfig(): BuilderConfig { workerConcurrency: 2, }, }, - plugins: [], + plugins: [thumbnailStoragePlugin()], } } diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts index 6f3714c7..3917e0ea 100644 --- a/packages/builder/src/index.ts +++ b/packages/builder/src/index.ts @@ -22,6 +22,8 @@ export type { LocalStoragePluginOptions } from './plugins/storage/local.js' export { default as localStoragePlugin } from './plugins/storage/local.js' export type { S3StoragePluginOptions } from './plugins/storage/s3.js' export { default as s3StoragePlugin } from './plugins/storage/s3.js' +export type { ThumbnailStoragePluginOptions } from './plugins/thumbnail-storage/index.js' +export { default as thumbnailStoragePlugin } from './plugins/thumbnail-storage/index.js' export type { BuilderPlugin, BuilderPluginConfigEntry, diff --git a/packages/builder/src/photo/image-pipeline.ts b/packages/builder/src/photo/image-pipeline.ts index 9a66e476..eeeb3a07 100644 --- a/packages/builder/src/photo/image-pipeline.ts +++ b/packages/builder/src/photo/image-pipeline.ts @@ -13,6 +13,7 @@ import { 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 { shouldProcessPhoto } from './cache-manager.js' import { processExifData, processThumbnailAndBlurhash, processToneAnalysis } from './data-processors.js' @@ -162,6 +163,13 @@ export async function executePhotoProcessingPipeline( // 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) diff --git a/packages/builder/src/plugins/thumbnail-storage/index.ts b/packages/builder/src/plugins/thumbnail-storage/index.ts new file mode 100644 index 00000000..73d3ecf5 --- /dev/null +++ b/packages/builder/src/plugins/thumbnail-storage/index.ts @@ -0,0 +1,182 @@ +import { StorageManager } from '../../storage/index.js' +import type { StorageConfig } from '../../storage/interfaces.js' +import type { BuilderPlugin } from '../types.js' +import type {ThumbnailPluginData} from './shared.js'; +import { THUMBNAIL_PLUGIN_DATA_KEY } from './shared.js' + +const DEFAULT_DIRECTORY = '.afilmory/thumbnails' +const DEFAULT_CONTENT_TYPE = 'image/jpeg' +const PLUGIN_NAME = 'afilmory:thumbnail-storage' +const RUN_STATE_KEY = 'state' + +type UploadableStorageConfig = Exclude + +interface ThumbnailStoragePluginOptions { + directory?: string + storageConfig?: UploadableStorageConfig + contentType?: string +} + +interface ResolvedPluginConfig { + directory: string + remotePrefix: string + contentType: string + useDefaultStorage: boolean + storageConfig: UploadableStorageConfig | null + enabled: boolean +} + +interface PluginRunState { + uploaded: Set + urlCache: Map +} + +function normalizeDirectory(directory: string | undefined): string { + const value = directory?.trim() || DEFAULT_DIRECTORY + const normalized = value.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + return normalized || DEFAULT_DIRECTORY +} + +function trimSlashes(value: string | undefined | null): string | null { + if (!value) return null + const normalized = value.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + return normalized.length > 0 ? normalized : null +} + +function joinSegments(...segments: Array): string { + const filtered = segments + .map((segment) => (segment ?? '').replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')) + .filter((segment) => segment.length > 0) + return filtered.join('/') +} + +function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string { + switch (config.provider) { + case 's3': { + const base = trimSlashes(config.prefix) + return joinSegments(base, directory) + } + case 'github': { + return joinSegments(directory) + } + case 'local': { + return joinSegments(directory) + } + default: { + return joinSegments(directory) + } + } +} + +function getOrCreateRunState(container: Map): PluginRunState { + let state = container.get(RUN_STATE_KEY) as PluginRunState | undefined + if (!state) { + state = { + uploaded: new Set(), + urlCache: new Map(), + } + container.set(RUN_STATE_KEY, state) + } + return state +} + +export default function thumbnailStoragePlugin(options: ThumbnailStoragePluginOptions = {}): BuilderPlugin { + let resolved: ResolvedPluginConfig | null = null + let externalStorageManager: StorageManager | null = null + + return { + name: PLUGIN_NAME, + hooks: { + onInit: ({ builder, config, logger }) => { + const storageConfig = (options.storageConfig ?? config.storage) as StorageConfig + const directory = normalizeDirectory(options.directory) + const contentType = options.contentType ?? DEFAULT_CONTENT_TYPE + + if (storageConfig.provider === 'eagle') { + logger.thumbnail.warn('缩略图上传插件不支持 Eagle 存储提供商,已自动禁用。') + externalStorageManager = null + resolved = { + directory, + remotePrefix: '', + contentType, + useDefaultStorage: !options.storageConfig, + storageConfig: null, + enabled: false, + } + return + } + + const uploadableConfig = storageConfig as UploadableStorageConfig + const remotePrefix = resolveRemotePrefix(uploadableConfig, directory) + + resolved = { + directory, + remotePrefix, + contentType, + useDefaultStorage: !options.storageConfig, + storageConfig: uploadableConfig, + enabled: true, + } + + if (!options.storageConfig) { + builder.getStorageManager().addExcludePrefix(remotePrefix) + } else { + externalStorageManager = new StorageManager(uploadableConfig) + } + }, + afterPhotoProcess: async ({ builder, payload, runShared, logger }) => { + if (!resolved) { + logger.main.warn('Thumbnail storage plugin is not initialized correctly. Skipping upload.') + return + } + + if (!resolved.enabled) { + return + } + + const data = payload.context.pluginData[THUMBNAIL_PLUGIN_DATA_KEY] as ThumbnailPluginData | undefined + + if (!data || !data.buffer || !payload.result.item) { + return + } + + const storageManager = resolved.useDefaultStorage ? builder.getStorageManager() : externalStorageManager + + if (!storageManager) { + logger.main.warn('Thumbnail storage plugin could not resolve storage manager. Skipping upload.') + return + } + + const remoteKey = joinSegments(resolved.remotePrefix, data.fileName) + const state = getOrCreateRunState(runShared) + + if (!state.uploaded.has(remoteKey)) { + try { + await storageManager.uploadFile(remoteKey, data.buffer, { + contentType: resolved.contentType, + }) + state.uploaded.add(remoteKey) + } catch (error) { + logger.thumbnail.error(`上传缩略图失败:${remoteKey}`, error) + return + } + } + + let remoteUrl = state.urlCache.get(remoteKey) + if (!remoteUrl) { + try { + remoteUrl = await storageManager.generatePublicUrl(remoteKey) + state.urlCache.set(remoteKey, remoteUrl) + } catch (error) { + logger.thumbnail.error(`生成缩略图 URL 失败:${remoteKey}`, error) + return + } + } + + payload.result.item.thumbnailUrl = remoteUrl + }, + }, + } +} + +export type { ThumbnailStoragePluginOptions } diff --git a/packages/builder/src/plugins/thumbnail-storage/shared.ts b/packages/builder/src/plugins/thumbnail-storage/shared.ts new file mode 100644 index 00000000..95ee4e09 --- /dev/null +++ b/packages/builder/src/plugins/thumbnail-storage/shared.ts @@ -0,0 +1,10 @@ +import type { Buffer } from 'node:buffer' + +export const THUMBNAIL_PLUGIN_DATA_KEY = 'afilmory:thumbnail-storage:data' + +export interface ThumbnailPluginData { + photoId: string + fileName: string + buffer: Buffer | null + localUrl: string | null +} diff --git a/packages/builder/src/storage/manager.ts b/packages/builder/src/storage/manager.ts index 38c8c359..bcd9b271 100644 --- a/packages/builder/src/storage/manager.ts +++ b/packages/builder/src/storage/manager.ts @@ -3,11 +3,24 @@ import type { StorageConfig, StorageObject, StorageProvider, StorageUploadOption export class StorageManager { private provider: StorageProvider + private readonly excludeFilters: Array<(key: string) => boolean> = [] constructor(config: StorageConfig) { this.provider = StorageFactory.createProvider(config) } + private applyExcludes(objects: T[]): T[] { + if (this.excludeFilters.length === 0) { + return objects + } + + return objects.filter((obj) => { + const { key } = obj + if (!key) return true + return !this.excludeFilters.some((filter) => filter(key)) + }) + } + /** * 从存储中获取文件 * @param key 文件的键值/路径 @@ -23,7 +36,8 @@ export class StorageManager { * @returns 图片文件对象数组 */ async listImages(): Promise { - return this.provider.listImages() + const objects = await this.provider.listImages() + return this.applyExcludes(objects) } /** @@ -31,7 +45,8 @@ export class StorageManager { * @returns 所有文件对象数组 */ async listAllFiles(): Promise { - return this.provider.listAllFiles() + const objects = await this.provider.listAllFiles() + return this.applyExcludes(objects) } /** @@ -49,8 +64,9 @@ export class StorageManager { * @returns Live Photo 配对映射 (图片 key -> 视频对象) */ async detectLivePhotos(allObjects?: StorageObject[]): Promise> { - const objects = allObjects || (await this.listAllFiles()) - return this.provider.detectLivePhotos(objects) + const sourceObjects = allObjects ?? (await this.provider.listAllFiles()) + const filtered = this.applyExcludes(sourceObjects) + return this.provider.detectLivePhotos(filtered) } async deleteFile(key: string): Promise { @@ -61,6 +77,20 @@ export class StorageManager { return await this.provider.uploadFile(key, data, options) } + addExcludeFilter(filter: (key: string) => boolean): void { + this.excludeFilters.push(filter) + } + + addExcludePrefix(prefix: string): void { + const normalized = prefix.replaceAll('\\', '/').replace(/^\/+/, '') + if (!normalized) { + return + } + + const effectivePrefix = normalized.endsWith('/') ? normalized : `${normalized}/` + this.addExcludeFilter((key) => key.startsWith(effectivePrefix)) + } + /** * 获取当前使用的存储提供商 * @returns 存储提供商实例