feat(builder): upload thumbnails via storage plugin

This commit is contained in:
Innei
2025-10-31 13:45:34 +08:00
parent ba68be802e
commit c8bb715b22
8 changed files with 266 additions and 9 deletions

View File

@@ -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()],
}
}
}

View File

@@ -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()],
}))

View File

@@ -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()],
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<StorageConfig, { provider: 'eagle' }>
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<string>
urlCache: Map<string, string>
}
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 | null | undefined>): 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<string, unknown>): PluginRunState {
let state = container.get(RUN_STATE_KEY) as PluginRunState | undefined
if (!state) {
state = {
uploaded: new Set<string>(),
urlCache: new Map<string, string>(),
}
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 }

View File

@@ -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
}

View File

@@ -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<T extends StorageObject>(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<StorageObject[]> {
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<StorageObject[]> {
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<Map<string, StorageObject>> {
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<void> {
@@ -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 存储提供商实例