mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(builder): upload thumbnails via storage plugin
This commit is contained in:
@@ -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()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
}))
|
||||
|
||||
@@ -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()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
182
packages/builder/src/plugins/thumbnail-storage/index.ts
Normal file
182
packages/builder/src/plugins/thumbnail-storage/index.ts
Normal 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 }
|
||||
10
packages/builder/src/plugins/thumbnail-storage/shared.ts
Normal file
10
packages/builder/src/plugins/thumbnail-storage/shared.ts
Normal 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
|
||||
}
|
||||
@@ -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 存储提供商实例
|
||||
|
||||
Reference in New Issue
Block a user