mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()],
|
||||
}
|
||||
}
|
||||
|
||||
5
packages/builder/src/config/helper.ts
Normal file
5
packages/builder/src/config/helper.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BuilderConfigInput } from '../types/config'
|
||||
|
||||
export function defineBuilderConfig(config: BuilderConfigInput | (() => BuilderConfigInput)): BuilderConfigInput {
|
||||
return typeof config === 'function' ? config() : config
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
190
packages/builder/src/plugins/thumbnail-storage/index.ts
Normal file
190
packages/builder/src/plugins/thumbnail-storage/index.ts
Normal 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'
|
||||
18
packages/builder/src/plugins/thumbnail-storage/shared.ts
Normal file
18
packages/builder/src/plugins/thumbnail-storage/shared.ts
Normal 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'
|
||||
@@ -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 存储提供商实例
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user