feat(builder): upload thumbnails via storage plugin (#137)

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Innei
2025-10-31 21:49:31 +08:00
committed by GitHub
parent ba68be802e
commit e27b45ec49
116 changed files with 3541 additions and 1761 deletions

View File

@@ -12,15 +12,15 @@
"cli": "tsx src/cli.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "3.916.0",
"@aws-sdk/client-s3": "3.921.0",
"@aws-sdk/node-http-handler": "3.374.0",
"@aws-sdk/s3-request-presigner": "3.916.0",
"@aws-sdk/s3-request-presigner": "3.921.0",
"@vingle/bmp-js": "^0.2.5",
"blurhash": "2.0.5",
"c12": "^1.11.2",
"c12": "^3.3.1",
"dotenv-expand": "catalog:",
"execa": "9.6.0",
"exiftool-vendored": "31.1.0",
"exiftool-vendored": "31.2.0",
"heic-convert": "2.1.0",
"heic-to": "1.3.0",
"sharp": "0.34.4",
@@ -28,7 +28,7 @@
},
"devDependencies": {
"@afilmory/utils": "workspace:*",
"tsdown": "0.15.9"
"tsdown": "0.15.12"
},
"publishConfig": {
"access": "public",

View File

@@ -18,7 +18,6 @@ import { StorageFactory, StorageManager } from '../storage/index.js'
import type { BuilderConfig } from '../types/config.js'
import type { AfilmoryManifest, CameraInfo, LensInfo } from '../types/manifest.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import { clone } from '../utils/clone.js'
import { ClusterPool } from '../worker/cluster-pool.js'
import { WorkerPool } from '../worker/pool.js'
@@ -45,8 +44,7 @@ export class AfilmoryBuilder {
private readonly pluginReferences: BuilderPluginConfigEntry[]
constructor(config: BuilderConfig) {
// 创建配置副本,避免外部修改
this.config = clone(config)
this.config = config
this.pluginReferences = this.resolvePluginReferences()
@@ -587,7 +585,7 @@ export class AfilmoryBuilder {
* 获取当前配置
*/
getConfig(): BuilderConfig {
return clone(this.config)
return Object.freeze(this.config)
}
/**

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

@@ -0,0 +1,5 @@
import type { BuilderConfigInput } from '../types/config'
export function defineBuilderConfig(config: BuilderConfigInput | (() => BuilderConfigInput)): BuilderConfigInput {
return typeof config === 'function' ? config() : config
}

View File

@@ -12,10 +12,6 @@ export interface LoadBuilderConfigOptions {
defaults?: BuilderConfig
}
export function defineBuilderConfig(config: BuilderConfigInput | (() => BuilderConfigInput)): BuilderConfigInput {
return typeof config === 'function' ? config() : config
}
function normalizeBuilderConfig(defaults: BuilderConfig, input: BuilderConfigInput): BuilderConfig {
const base = clone(defaults)
const merged = merge(base, input as Record<string, unknown>) as BuilderConfig

View File

@@ -2,8 +2,8 @@ export * from '../../utils/src/u8array.js'
export type { BuilderOptions, BuilderResult } from './builder/index.js'
export { AfilmoryBuilder } from './builder/index.js'
export { createDefaultBuilderConfig } from './config/defaults.js'
export { defineBuilderConfig } from './config/helper.js'
export type { LoadBuilderConfigOptions } from './config/index.js'
export { defineBuilderConfig, loadBuilderConfig } from './config/index.js'
export type { PhotoProcessingContext, ProcessedImageData } from './photo/image-pipeline.js'
export {
executePhotoProcessingPipeline,
@@ -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 { THUMBNAIL_PLUGIN_SYMBOL, 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,190 @@
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 {
DEFAULT_CONTENT_TYPE,
DEFAULT_DIRECTORY,
THUMBNAIL_PLUGIN_DATA_KEY,
THUMBNAIL_PLUGIN_SYMBOL,
} from './shared.js'
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
const plugin: BuilderPlugin & { [THUMBNAIL_PLUGIN_SYMBOL]: true } = {
name: PLUGIN_NAME,
[THUMBNAIL_PLUGIN_SYMBOL]: true,
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
},
},
}
return plugin
}
export type { ThumbnailStoragePluginOptions }
export { THUMBNAIL_PLUGIN_SYMBOL } from './shared.js'

View File

@@ -0,0 +1,18 @@
import type { Buffer } from 'node:buffer'
export const THUMBNAIL_PLUGIN_DATA_KEY = 'afilmory:thumbnail-storage:data'
/**
* Unique symbol identifier for the thumbnail storage plugin.
* Used for reliable plugin detection without fragile string matching.
*/
export const THUMBNAIL_PLUGIN_SYMBOL = Symbol.for('afilmory:thumbnail-storage')
export interface ThumbnailPluginData {
photoId: string
fileName: string
buffer: Buffer | null
localUrl: string | null
}
export const DEFAULT_DIRECTORY = '.afilmory/thumbnails'
export const DEFAULT_CONTENT_TYPE = 'image/jpeg'

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 存储提供商实例

View File

@@ -22,7 +22,7 @@
"@radix-ui/react-scroll-area": "1.2.10",
"@tailwindcss/vite": "4.1.16",
"@types/mdast": "^4.0.4",
"lucide-react": "^0.547.0",
"lucide-react": "^0.552.0",
"mdast": "^3.0.0",
"motion": "12.23.24",
"next-themes": "^0.4.6",
@@ -38,7 +38,7 @@
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@shikijs/rehype": "^3.13.0",
"@shikijs/rehype": "^3.14.0",
"@tailwindcss/postcss": "catalog:",
"@tailwindcss/typography": "catalog:",
"@types/glob": "^9.0.0",
@@ -49,11 +49,11 @@
"@vitejs/plugin-react": "^5.1.0",
"code-inspector-plugin": "1.2.10",
"eslint": "^9.38.0",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"glob": "^11.0.3",
"globals": "^16.4.0",
"shiki": "^3.13.0",
"shiki": "^3.14.0",
"tailwind-scrollbar": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-animate": "catalog:",

View File

@@ -29,7 +29,7 @@
"clsx": "^2.1.1",
"jotai": "^2.15.0",
"motion": "^12.23.24",
"react-intersection-observer": "9.16.0",
"react-intersection-observer": "10.0.0",
"sonner": "2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",

View File

@@ -7,7 +7,6 @@ const toastStyles = {
group relative flex w-full items-center justify-between gap-3 rounded-2xl p-4
backdrop-blur-2xl duration-300 ease-out overflow-hidden
max-w-md min-w-[320px]
font-theme
[&]:border [&]:border-solid
[&]:data-[type=default]:border-[rgba(255,92,0,0.2)]
[&]:data-[type=success]:border-[rgba(40,205,65,0.2)]

View File

@@ -19,9 +19,9 @@
},
"devDependencies": {
"@types/react": "19.2.2",
"nbump": "2.1.7",
"nbump": "2.1.8",
"react": "19.2.0",
"tsdown": "0.15.9",
"tsdown": "0.15.12",
"unplugin-dts": "1.0.0-beta.6",
"vite": "7.1.12"
},