refactor!: builder pipe and plugin system

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-10-28 19:48:04 +08:00
parent ad0c8830a6
commit af170a43bb
42 changed files with 1896 additions and 806 deletions

View File

@@ -1,132 +0,0 @@
# Afilmory Codebase Guide for AI Agents
## Project Architecture
Afilmory is a modern photo gallery monorepo with sophisticated image processing capabilities:
- **`apps/ssr/`** - Next.js 15 SSR application with database and APIs
- **`apps/web/`** - React 19 frontend with Vite, WebGL viewer, masonry layout
- **`packages/builder/`** - Photo processing engine with S3/GitHub storage adapters
- **`packages/docs/`** - Vite SSG documentation site (planned custom implementation)
- **`packages/webgl-viewer/`** - High-performance WebGL photo viewer component
## Critical Development Commands
```bash
# Photo processing & manifest generation
pnpm run build:manifest # Incremental photo sync
pnpm run build:manifest -- --force # Full rebuild
pnpm run build:manifest -- --force-thumbnails # Regenerate thumbnails only
# Development servers
pnpm dev # Starts both SSR + web apps
pnpm --filter @afilmory/ssr dev # SSR only (Next.js on :1924)
pnpm --filter web dev # Web only (Vite)
# Database operations (SSR app)
pnpm --filter @afilmory/ssr db:generate # Generate Drizzle migrations
pnpm --filter @afilmory/ssr db:migrate # Run migrations
```
## Code Quality & Architecture Patterns
### State Management Architecture
- **Jotai** for client state in web app - use atomic patterns, avoid large atoms
- **TanStack Query** for server state - leverage cache invalidation patterns
- **React Context** - follow composition over deep nesting, prevent re-renders
### Photo Processing Pipeline
1. **Storage Adapters** (`packages/builder/src/storage/`) - implement `StorageProvider` interface
2. **Format Conversion** - HEIC/TIFF → web formats via Sharp
3. **EXIF Extraction** - camera settings, GPS, Fujifilm recipes
4. **Concurrent Processing** - worker threads/cluster mode in `builderConfig.performance`
### WebGL Integration
- Custom WebGL viewer in `packages/webgl-viewer/` for high-performance rendering
- Gesture support, zoom/pan operations
- Integration with masonry layout via Masonic
## Project-Specific Conventions
### Configuration System
- **`builder.config.json`** - photo processing, storage, performance settings
- **`config.json`** - site metadata merged with `site.config.ts` defaults
- **`env.ts`** - centralized environment validation with Zod schemas
### Apple UIKit Color System
```typescript
// Use semantic Tailwind classes from tailwindcss-uikit-colors
className = 'text-text-primary bg-fill-secondary material-thin'
// NOT generic colors like "text-blue-500"
```
### i18n Flat Key Structure
```json
// locales/app/en.json - use dot notation, avoid nesting
{
"exif.camera.model": "Camera Model",
"photo.count_one": "{{count}} photo",
"photo.count_other": "{{count}} photos"
}
```
### Monorepo Workspace Patterns
- Use `pnpm --filter <package>` for targeted operations
- Shared dependencies in `pnpm-workspace.yaml` catalog
- Cross-package imports via workspace protocol: `"@afilmory/components": "workspace:*"`
## Storage Provider Integration
When extending storage support, implement the adapter pattern:
```typescript
// packages/builder/src/storage/providers/
class NewStorageProvider implements StorageProvider {
async listImages(): Promise<StorageObject[]> {
/* */
}
async getFile(key: string): Promise<Buffer | null> {
/* */
}
// Key methods for storage abstraction
}
```
## Performance Considerations
- **Photo Processing**: Configure worker pools in `builderConfig.performance.worker`
- **WebGL Viewer**: Implement texture memory management and disposal
- **Bundle Splitting**: Leverage Vite's code splitting for image processing tools
- **Image Optimization**: Use Sharp for thumbnails, Blurhash for placeholders
## Documentation Site (packages/docs/)
Currently planned as a custom Vite SSG implementation:
- **MDX** with React components, math (KaTeX), charts (Mermaid)
- **Custom Vite plugins** for file-system routing and search indexing
- **Design system** aligned with main app's Apple UIKit colors
- Reference `packages/docs/requirements.md` and `tasks.md` for implementation details
## Integration Points
- **Database**: Drizzle ORM with PostgreSQL for SSR app
- **Image CDN**: S3-compatible storage with custom domain support
- **Map Integration**: MapLibre for GPS photo locations
- **Live Photos**: iOS video detection and playback support
- **RSS/Social**: OpenGraph metadata and feed generation
## Other Considerations
- Don't have to implement everything at once; focus on core features first
- Use existing packages as references for implementation patterns
- Follow the established architecture for consistency
- Keep documentation up-to-date with code changes
- If you need to run commands, ask for help, don't run them blindly

2
.gitignore vendored
View File

@@ -12,7 +12,7 @@ thomas-x2d-xcd-25v-1.webp
config.json
.env
builder.config.json
builder.config.ts
apps/web/assets-git
apps/web/src/data/photos-manifest.json

View File

@@ -97,15 +97,8 @@ This is a pnpm workspace with multiple applications and packages:
**Two-Layer Configuration System**:
1. **Builder Config** (`builder.config.json`) - **Infrastructure/Processing Layer**:
1. **Builder Config** (`builder.config.ts`) - **Infrastructure/Processing Layer**:
```json
{
"storage": { "provider": "s3", "bucket": "...", "region": "..." },
"performance": { "worker": { "workerCount": 8, "useClusterMode": true } },
"repo": { "enable": true, "url": "...", "token": "..." }
}
```
- Controls photo processing, storage connections, and build performance
- Handles remote git repository sync for manifest/thumbnails
- Configures multi-process/cluster processing for large photo sets

View File

@@ -164,7 +164,7 @@ pnpm build
### Notes
- Ensure your S3 bucket already contains photo files
- If using remote repository, configure `builder.config.json` first
- If using remote repository, configure `builder.config.ts` first
## 🔧 Advanced Usage

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/types/routes.d.ts'
import './.next/dev/types/routes.d.ts'
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -5,7 +5,7 @@ import { $ } from 'execa'
export const precheck = async () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const workdir = path.resolve(__dirname, '..')
const workdir = path.resolve(__dirname, '../../..')
await $({
cwd: workdir,

View File

@@ -30,9 +30,6 @@
"@config": [
"../../site.config.ts"
],
"@builder": [
"../../builder.config.ts"
],
"@env": [
"../../env.ts"
],
@@ -45,4 +42,4 @@
"./src/**/*",
"./scripts/**/*"
]
}
}

View File

@@ -66,9 +66,6 @@ export const ProviderCard: FC<ProviderCardProps> = ({
case 'local': {
return cfg.path || 'Not configured'
}
case 'minio': {
return cfg.endpoint || 'Not configured'
}
case 'eagle': {
return cfg.libraryPath || 'Not configured'
}

View File

@@ -1,21 +1,15 @@
import cluster from 'node:cluster'
import { existsSync, readFileSync } from 'node:fs'
import os from 'node:os'
import { inspect } from 'node:util'
import type { BuilderConfig } from '@afilmory/builder'
import consola from 'consola'
import { merge } from 'es-toolkit'
import { defineBuilderConfig } from '@afilmory/builder'
import { env } from './env.js'
export const defaultBuilderConfig: BuilderConfig = {
export default defineBuilderConfig(() => ({
repo: {
enable: false,
url: '',
url: process.env.BUILDER_REPO_URL ?? '',
token: env.GIT_TOKEN,
},
storage: {
provider: 's3',
bucket: env.S3_BUCKET_NAME,
@@ -27,7 +21,6 @@ export const defaultBuilderConfig: BuilderConfig = {
customDomain: env.S3_CUSTOM_DOMAIN,
excludeRegex: env.S3_EXCLUDE_REGEX,
maxFileLimit: 1000,
// Network tuning defaults
keepAlive: true,
maxSockets: 64,
connectionTimeoutMs: 5_000,
@@ -39,7 +32,6 @@ export const defaultBuilderConfig: BuilderConfig = {
maxAttempts: 3,
downloadConcurrency: 16,
},
options: {
defaultConcurrency: 10,
enableLivePhotoDetection: true,
@@ -47,42 +39,18 @@ export const defaultBuilderConfig: BuilderConfig = {
showDetailedStats: true,
digestSuffixLength: 0,
},
logging: {
verbose: false,
level: 'info',
outputToFile: false,
},
performance: {
worker: {
workerCount: os.cpus().length * 2,
timeout: 30000, // 30 seconds
timeout: 30_000,
useClusterMode: true,
workerConcurrency: 2,
},
},
}
const readUserConfig = () => {
const isUserConfigExist = existsSync(
new URL('builder.config.json', import.meta.url),
)
if (!isUserConfigExist) {
return defaultBuilderConfig
}
const userConfig = JSON.parse(
readFileSync(new URL('builder.config.json', import.meta.url), 'utf-8'),
) as BuilderConfig
return merge(defaultBuilderConfig, userConfig)
}
export const builderConfig: BuilderConfig = readUserConfig()
if (cluster.isPrimary && process.env.DEBUG === '1') {
const logger = consola.withTag('CONFIG')
logger.info('Your builder config:')
logger.info(inspect(builderConfig, { depth: null, colors: true }))
}
plugins: [],
}))

View File

@@ -1,10 +0,0 @@
{
"storage": {
"provider": "s3",
"bucket": "my-photos",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "https://cdn.example.com",
"endpoint": "https://s3.amazonaws.com"
}
}

View File

@@ -49,6 +49,7 @@
"eslint-config-hyoban": "4.0.10",
"fast-glob": "3.3.3",
"lint-staged": "16.2.6",
"nbump": "2.1.8",
"opentype.js": "1.3.4",
"prettier": "3.6.2",
"sharp": "0.34.4",

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'nbump'
export default defineConfig({
leading: ['npm run build'],
publish: true,
allowDirty: true,
allowedBranches: ['dev/*', 'main'],
withTags: false,
tag: false,
commit: false,
push: false,
})

View File

@@ -1,22 +1,23 @@
{
"name": "@afilmory/builder",
"type": "module",
"version": "0.0.1",
"private": true,
"version": "0.1.3",
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
},
"scripts": {
"build": "tsdown",
"bump": "nbump",
"cli": "tsx src/cli.ts"
},
"dependencies": {
"@afilmory/utils": "workspace:*",
"@aws-sdk/client-s3": "3.916.0",
"@aws-sdk/node-http-handler": "3.374.0",
"@aws-sdk/s3-request-presigner": "3.916.0",
"@vingle/bmp-js": "^0.2.5",
"blurhash": "2.0.5",
"c12": "^1.11.2",
"dotenv-expand": "catalog:",
"execa": "9.6.0",
"exiftool-vendored": "31.1.0",
@@ -24,5 +25,23 @@
"heic-to": "1.3.0",
"sharp": "0.34.4",
"thumbhash": "0.1.1"
},
"devDependencies": {
"@afilmory/utils": "workspace:*",
"tsdown": "0.15.9"
},
"publishConfig": {
"access": "public",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js"
}
}
}
}
}

View File

@@ -1,5 +1,4 @@
import path from 'node:path'
import { deserialize as v8Deserialize, serialize as v8Serialize } from 'node:v8'
import { thumbnailExists } from '../image/thumbnail.js'
import { logger } from '../logger/index.js'
@@ -12,7 +11,15 @@ import {
import { CURRENT_MANIFEST_VERSION } from '../manifest/version.js'
import type { PhotoProcessorOptions } from '../photo/processor.js'
import { processPhoto } from '../photo/processor.js'
import { StorageManager } from '../storage/index.js'
import type { PluginRunState } from '../plugins/manager.js'
import { PluginManager } from '../plugins/manager.js'
import type {
BuilderPluginConfigEntry,
BuilderPluginEventPayloads,
} from '../plugins/types.js'
import { isPluginReferenceObject } from '../plugins/types.js'
import type { StorageProviderFactory } from '../storage/factory.js'
import { StorageFactory, StorageManager } from '../storage/index.js'
import type { BuilderConfig } from '../types/config.js'
import type {
AfilmoryManifest,
@@ -20,6 +27,7 @@ import type {
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'
@@ -40,15 +48,20 @@ export interface BuilderResult {
}
export class AfilmoryBuilder {
private storageManager: StorageManager
private storageManager: StorageManager | null = null
private config: BuilderConfig
private pluginManager: PluginManager
private readonly pluginReferences: BuilderPluginConfigEntry[]
constructor(config: BuilderConfig) {
// 创建配置副本,避免外部修改
this.config = cloneConfig(config)
this.config = clone(config)
// 创建存储管理器
this.storageManager = new StorageManager(this.config.storage)
this.pluginReferences = this.resolvePluginReferences()
this.pluginManager = new PluginManager(this.pluginReferences, {
baseDir: process.cwd(),
})
// 配置日志级别(保留接口以便未来扩展)
this.configureLogging()
@@ -60,6 +73,8 @@ export class AfilmoryBuilder {
async buildManifest(options: BuilderOptions): Promise<BuilderResult> {
try {
await this.ensurePluginsReady()
this.ensureStorageManager()
return await this.#buildManifest(options)
} catch (error) {
logger.main.error('❌ 构建 manifest 失败:', error)
@@ -72,99 +87,120 @@ export class AfilmoryBuilder {
*/
async #buildManifest(options: BuilderOptions): Promise<BuilderResult> {
const startTime = Date.now()
this.logBuildStart()
// 读取现有的 manifest如果存在
const existingManifestItems = await this.loadExistingManifest(options).then(
(manifest) => manifest.data,
)
const existingManifestMap = new Map(
existingManifestItems.map((item) => [item.s3Key, item]),
)
logger.main.info(
`现有 manifest 包含 ${existingManifestItems.length} 张照片`,
)
logger.main.info('使用存储提供商:', this.config.storage.provider)
// 列出存储中的所有文件
const allObjects = await this.storageManager.listAllFiles()
logger.main.info(`存储中找到 ${allObjects.length} 个文件`)
// 检测 Live Photo 配对(如果启用)
const livePhotoMap = await this.detectLivePhotos(allObjects)
if (this.config.options.enableLivePhotoDetection) {
logger.main.info(`检测到 ${livePhotoMap.size} 个 Live Photo`)
}
// 列出存储中的所有图片文件
const imageObjects = await this.storageManager.listImages()
logger.main.info(`存储中找到 ${imageObjects.length} 张照片`)
// 创建存储中存在的图片 key 集合,用于检测已删除的图片
const s3ImageKeys = new Set(imageObjects.map((obj) => obj.key))
const runState = this.pluginManager.createRunState()
const manifest: PhotoManifestItem[] = []
const processingResults: ProcessPhotoResult[] = []
let processedCount = 0
let skippedCount = 0
let newCount = 0
let deletedCount = 0
if (imageObjects.length === 0) {
logger.main.error('❌ 没有找到需要处理的照片')
return {
hasUpdates: false,
newCount: 0,
processedCount: 0,
skippedCount: 0,
deletedCount: 0,
totalPhotos: 0,
}
}
try {
await this.emitPluginEvent(runState, 'beforeBuild', {
options,
})
// 筛选出实际需要处理的图片
let tasksToProcess = await this.filterTaskImages(
imageObjects,
existingManifestMap,
options,
)
this.logBuildStart()
logger.main.info(
`存储中找到 ${imageObjects.length} 张照片,实际需要处理 ${tasksToProcess.length}`,
)
// 为减少尾部长耗时,按文件大小降序处理(优先处理大文件)
if (tasksToProcess.length > 1) {
const beforeFirst = tasksToProcess[0]?.key
tasksToProcess = tasksToProcess.sort(
(a, b) => (b.size ?? 0) - (a.size ?? 0),
// 读取现有的 manifest如果存在
const existingManifest = await this.loadExistingManifest(options)
const existingManifestItems = existingManifest.data
const existingManifestMap = new Map(
existingManifestItems.map((item) => [item.s3Key, item]),
)
if (beforeFirst !== tasksToProcess[0]?.key) {
logger.main.info('已按文件大小降序重排处理队列')
}
}
// 如果没有任务需要处理,直接使用现有的 manifest
if (tasksToProcess.length === 0) {
logger.main.info('💡 没有需要处理的照片,使用现有 manifest')
manifest.push(
...existingManifestItems.filter((item) => s3ImageKeys.has(item.s3Key)),
)
} else {
// 获取并发限制
const concurrency =
options.concurrencyLimit ?? this.config.options.defaultConcurrency
// 根据配置和实际任务数量选择处理模式
const { useClusterMode } = this.config.performance.worker
// 如果实际任务数量较少,则不使用 cluster 模式
const shouldUseCluster =
useClusterMode && tasksToProcess.length >= concurrency * 2
await this.emitPluginEvent(runState, 'afterManifestLoad', {
options,
manifest: existingManifest,
manifestMap: existingManifestMap,
})
logger.main.info(
`开始${shouldUseCluster ? '多进程' : '并发'}处理任务,${shouldUseCluster ? '进程' : 'Worker'}数:${concurrency}${shouldUseCluster ? `,每进程并发:${this.config.performance.worker.workerConcurrency}` : ''}`,
`现有 manifest 包含 ${existingManifestItems.length} 张照片`,
)
logger.main.info('使用存储提供商:', this.config.storage.provider)
const storageManager = this.getStorageManager()
// 列出存储中的所有文件
const allObjects = await storageManager.listAllFiles()
logger.main.info(`存储中找到 ${allObjects.length} 个文件`)
await this.emitPluginEvent(runState, 'afterAllFilesListed', {
options,
allObjects,
})
// 检测 Live Photo 配对(如果启用)
const livePhotoMap = await this.detectLivePhotos(allObjects)
if (this.config.options.enableLivePhotoDetection) {
logger.main.info(`检测到 ${livePhotoMap.size} 个 Live Photo`)
}
await this.emitPluginEvent(runState, 'afterLivePhotoDetection', {
options,
livePhotoMap,
})
// 列出存储中的所有图片文件
const imageObjects = await storageManager.listImages()
logger.main.info(`存储中找到 ${imageObjects.length} 张照片`)
await this.emitPluginEvent(runState, 'afterImagesListed', {
options,
imageObjects,
})
if (imageObjects.length === 0) {
logger.main.error('❌ 没有找到需要处理的照片')
const result: BuilderResult = {
hasUpdates: false,
newCount: 0,
processedCount: 0,
skippedCount: 0,
deletedCount: 0,
totalPhotos: 0,
}
await this.emitPluginEvent(runState, 'afterBuild', {
options,
result,
manifest,
})
return result
}
// 创建存储中存在的图片 key 集合,用于检测已删除的图片
const s3ImageKeys = new Set(imageObjects.map((obj) => obj.key))
// 筛选出实际需要处理的图片
let tasksToProcess = await this.filterTaskImages(
imageObjects,
existingManifestMap,
options,
)
// 为减少尾部长耗时,按文件大小降序处理(优先处理大文件)
if (tasksToProcess.length > 1) {
const beforeFirst = tasksToProcess[0]?.key
tasksToProcess = tasksToProcess.sort(
(a, b) => (b.size ?? 0) - (a.size ?? 0),
)
if (beforeFirst !== tasksToProcess[0]?.key) {
logger.main.info('已按文件大小降序重排处理队列')
}
}
await this.emitPluginEvent(runState, 'afterTasksPrepared', {
options,
tasks: tasksToProcess,
totalImages: imageObjects.length,
})
logger.main.info(
`存储中找到 ${imageObjects.length} 张照片,实际需要处理 ${tasksToProcess.length}`,
)
const processorOptions: PhotoProcessorOptions = {
@@ -173,75 +209,115 @@ export class AfilmoryBuilder {
isForceThumbnails: options.isForceThumbnails,
}
let results: ProcessPhotoResult[]
const concurrency =
options.concurrencyLimit ?? this.config.options.defaultConcurrency
const { useClusterMode } = this.config.performance.worker
const shouldUseCluster =
useClusterMode && tasksToProcess.length >= concurrency * 2
if (shouldUseCluster) {
// 创建 Cluster 池(多进程模式)
const clusterPool = new ClusterPool<ProcessPhotoResult>({
concurrency,
totalTasks: tasksToProcess.length,
workerConcurrency: this.config.performance.worker.workerConcurrency,
workerEnv: {
FORCE_MODE: processorOptions.isForceMode.toString(),
FORCE_MANIFEST: processorOptions.isForceManifest.toString(),
FORCE_THUMBNAILS: processorOptions.isForceThumbnails.toString(),
},
sharedData: {
existingManifestMap,
livePhotoMap,
imageObjects: tasksToProcess,
builderConfig: this.getConfig(),
},
})
await this.emitPluginEvent(runState, 'beforeProcessTasks', {
options,
tasks: tasksToProcess,
processorOptions,
mode: shouldUseCluster ? 'cluster' : 'worker',
concurrency,
})
// 执行多进程并发处理
results = await clusterPool.execute()
if (tasksToProcess.length === 0) {
logger.main.info('💡 没有需要处理的照片,使用现有 manifest')
for (const item of existingManifestItems) {
if (!s3ImageKeys.has(item.s3Key)) continue
await this.emitPluginEvent(runState, 'beforeAddManifestItem', {
options,
item,
pluginData: {},
resultType: 'skipped',
})
manifest.push(item)
}
} else {
// 创建传统 Worker 池(主线程并发模式)
const workerPool = new WorkerPool<ProcessPhotoResult>({
concurrency,
totalTasks: tasksToProcess.length,
})
logger.main.info(
`开始${shouldUseCluster ? '多进程' : '并发'}处理任务,${shouldUseCluster ? '进程' : 'Worker'}数:${concurrency}${shouldUseCluster ? `,每进程并发:${this.config.performance.worker.workerConcurrency}` : ''}`,
)
// 执行并发处理
results = await workerPool.execute(async (taskIndex, workerId) => {
const obj = tasksToProcess[taskIndex]
let results: ProcessPhotoResult[]
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
const legacyObj = {
Key: obj.key,
Size: obj.size,
LastModified: obj.lastModified,
ETag: obj.etag,
}
if (shouldUseCluster) {
const clusterPool = new ClusterPool<ProcessPhotoResult>({
concurrency,
totalTasks: tasksToProcess.length,
workerConcurrency: this.config.performance.worker.workerConcurrency,
workerEnv: {
FORCE_MODE: processorOptions.isForceMode.toString(),
FORCE_MANIFEST: processorOptions.isForceManifest.toString(),
FORCE_THUMBNAILS: processorOptions.isForceThumbnails.toString(),
},
sharedData: {
existingManifestMap,
livePhotoMap,
imageObjects: tasksToProcess,
builderConfig: this.getConfig(),
},
})
// 转换 Live Photo Map
const legacyLivePhotoMap = new Map()
for (const [key, value] of livePhotoMap) {
legacyLivePhotoMap.set(key, {
Key: value.key,
Size: value.size,
LastModified: value.lastModified,
ETag: value.etag,
})
}
results = await clusterPool.execute()
} else {
const workerPool = new WorkerPool<ProcessPhotoResult>({
concurrency,
totalTasks: tasksToProcess.length,
})
return await processPhoto(
legacyObj,
taskIndex,
workerId,
tasksToProcess.length,
existingManifestMap,
legacyLivePhotoMap,
processorOptions,
this,
)
})
}
results = await workerPool.execute(async (taskIndex, workerId) => {
const obj = tasksToProcess[taskIndex]
const legacyObj = {
Key: obj.key,
Size: obj.size,
LastModified: obj.lastModified,
ETag: obj.etag,
}
const legacyLivePhotoMap = new Map()
for (const [key, value] of livePhotoMap) {
legacyLivePhotoMap.set(key, {
Key: value.key,
Size: value.size,
LastModified: value.lastModified,
ETag: value.etag,
})
}
return await processPhoto(
legacyObj,
taskIndex,
workerId,
tasksToProcess.length,
existingManifestMap,
legacyLivePhotoMap,
processorOptions,
this,
{
runState,
builderOptions: options,
},
)
})
}
processingResults.push(...results)
for (const result of results) {
if (!result.item) continue
await this.emitPluginEvent(runState, 'beforeAddManifestItem', {
options,
item: result.item,
pluginData: result.pluginData ?? {},
resultType: result.type,
})
// 统计结果并添加到 manifest
for (const result of results) {
if (result.item) {
manifest.push(result.item)
switch (result.type) {
@@ -260,50 +336,99 @@ export class AfilmoryBuilder {
}
}
}
}
// 添加未处理但仍然存在的照片到 manifest
for (const [key, item] of existingManifestMap) {
if (s3ImageKeys.has(key) && !manifest.some((m) => m.s3Key === key)) {
manifest.push(item)
skippedCount++
for (const [key, item] of existingManifestMap) {
if (s3ImageKeys.has(key) && !manifest.some((m) => m.s3Key === key)) {
await this.emitPluginEvent(runState, 'beforeAddManifestItem', {
options,
item,
pluginData: {},
resultType: 'skipped',
})
manifest.push(item)
skippedCount++
}
}
}
}
// 检测并处理已删除的图片
deletedCount = await handleDeletedPhotos(manifest)
// 生成相机和镜头集合
const cameras = this.generateCameraCollection(manifest)
const lenses = this.generateLensCollection(manifest)
// 保存 manifest
await saveManifest(manifest, cameras, lenses)
// 显示构建结果
if (this.config.options.showDetailedStats) {
this.logBuildResults(
await this.emitPluginEvent(runState, 'afterProcessTasks', {
options,
tasks: tasksToProcess,
results: processingResults,
manifest,
{
stats: {
newCount,
processedCount,
skippedCount,
deletedCount,
},
Date.now() - startTime,
)
}
})
// 返回构建结果
const hasUpdates = newCount > 0 || processedCount > 0 || deletedCount > 0
return {
hasUpdates,
newCount,
processedCount,
skippedCount,
deletedCount,
totalPhotos: manifest.length,
// 检测并处理已删除的图片
deletedCount = await handleDeletedPhotos(manifest)
await this.emitPluginEvent(runState, 'afterCleanup', {
options,
manifest,
deletedCount,
})
// 生成相机和镜头集合
const cameras = this.generateCameraCollection(manifest)
const lenses = this.generateLensCollection(manifest)
await this.emitPluginEvent(runState, 'beforeSaveManifest', {
options,
manifest,
cameras,
lenses,
})
await saveManifest(manifest, cameras, lenses)
await this.emitPluginEvent(runState, 'afterSaveManifest', {
options,
manifest,
cameras,
lenses,
})
if (this.config.options.showDetailedStats) {
this.logBuildResults(
manifest,
{
newCount,
processedCount,
skippedCount,
deletedCount,
},
Date.now() - startTime,
)
}
const hasUpdates = newCount > 0 || processedCount > 0 || deletedCount > 0
const result: BuilderResult = {
hasUpdates,
newCount,
processedCount,
skippedCount,
deletedCount,
totalPhotos: manifest.length,
}
await this.emitPluginEvent(runState, 'afterBuild', {
options,
result,
manifest,
})
return result
} catch (error) {
await this.emitPluginEvent(runState, 'onError', {
options,
error,
})
throw error
}
}
@@ -327,7 +452,7 @@ export class AfilmoryBuilder {
return new Map()
}
return await this.storageManager.detectLivePhotos(allObjects)
return await this.getStorageManager().detectLivePhotos(allObjects)
}
private logBuildStart(): void {
@@ -387,6 +512,113 @@ export class AfilmoryBuilder {
* 获取当前使用的存储管理器
*/
getStorageManager(): StorageManager {
return this.ensureStorageManager()
}
registerStorageProvider(
provider: string,
factory: StorageProviderFactory,
): void {
StorageFactory.registerProvider(provider, factory)
if (this.config.storage.provider === provider) {
this.storageManager = null
this.ensureStorageManager()
}
}
createPluginRunState(): PluginRunState {
return this.pluginManager.createRunState()
}
async emitPluginEvent<TEvent extends keyof BuilderPluginEventPayloads>(
runState: PluginRunState,
event: TEvent,
payload: BuilderPluginEventPayloads[TEvent],
): Promise<void> {
await this.pluginManager.emit(this, runState, event, payload)
}
async ensurePluginsReady(): Promise<void> {
await this.pluginManager.ensureLoaded(this)
}
private resolvePluginReferences(): BuilderPluginConfigEntry[] {
const references: BuilderPluginConfigEntry[] = []
const seen = new Set<string>()
const addReference = (ref: BuilderPluginConfigEntry) => {
if (typeof ref === 'string') {
if (seen.has(ref)) return
seen.add(ref)
references.push(ref)
return
}
if (isPluginReferenceObject(ref)) {
const key = ref.resolve
if (seen.has(key)) return
seen.add(key)
references.push(ref)
return
}
const pluginName = ref.name
if (pluginName) {
const key = `plugin:${pluginName}`
if (seen.has(key)) {
return
}
seen.add(key)
}
references.push(ref)
}
const hasPluginWithName = (name: string): boolean => {
return references.some((ref) => {
if (typeof ref === 'string' || isPluginReferenceObject(ref)) {
return false
}
return ref.name === name
})
}
for (const ref of this.config.plugins ?? []) {
addReference(ref)
}
if (
this.config.repo.enable &&
!hasPluginWithName('afilmory:github-repo-sync')
) {
addReference('@afilmory/builder/plugins/github-repo-sync')
}
const storagePluginByProvider: Record<string, string> = {
s3: '@afilmory/builder/plugins/storage/s3',
github: '@afilmory/builder/plugins/storage/github',
eagle: '@afilmory/builder/plugins/storage/eagle',
local: '@afilmory/builder/plugins/storage/local',
}
const storageProvider = this.config.storage.provider
const storagePlugin = storagePluginByProvider[storageProvider]
if (storagePlugin) {
const expectedName = `afilmory:storage:${storageProvider}`
if (hasPluginWithName(expectedName)) {
return references
}
addReference(storagePlugin)
}
return references
}
private ensureStorageManager(): StorageManager {
if (!this.storageManager) {
this.storageManager = new StorageManager(this.config.storage)
}
return this.storageManager
}
@@ -394,7 +626,7 @@ export class AfilmoryBuilder {
* 获取当前配置
*/
getConfig(): BuilderConfig {
return cloneConfig(this.config)
return clone(this.config)
}
/**
@@ -519,17 +751,3 @@ export class AfilmoryBuilder {
)
}
}
function cloneConfig<T>(value: T): T {
const maybeStructuredClone = (
globalThis as typeof globalThis & {
structuredClone?: <U>(input: U) => U
}
).structuredClone
if (typeof maybeStructuredClone === 'function') {
return maybeStructuredClone(value)
}
return v8Deserialize(v8Serialize(value))
}

View File

@@ -2,122 +2,15 @@ import 'dotenv-expand/config'
import { execSync } from 'node:child_process'
import cluster from 'node:cluster'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import { join } from 'node:path'
import process from 'node:process'
import { builderConfig } from '@builder'
import { $ } from 'execa'
import { fileURLToPath } from 'node:url'
import { AfilmoryBuilder } from './builder/index.js'
import { loadBuilderConfig } from './config/index.js'
import { logger } from './logger/index.js'
import { workdir } from './path.js'
import { runAsWorker } from './runAsWorker.js'
const cliBuilder = new AfilmoryBuilder(builderConfig)
/**
* 推送更新后的 manifest 到远程仓库
*/
async function pushManifestToRemoteRepo(): Promise<boolean> {
if (!builderConfig.repo.enable || !builderConfig.repo.token) {
if (!builderConfig.repo.enable) {
logger.main.info('🔧 远程仓库未启用,跳过推送')
} else {
logger.main.warn('⚠️ 未提供 Git Token跳过推送到远程仓库')
}
return false
}
try {
const assetsGitDir = path.resolve(workdir, 'assets-git')
if (!existsSync(assetsGitDir)) {
logger.main.error('❌ assets-git 目录不存在,无法推送')
return false
}
logger.main.info('📤 开始推送更新到远程仓库...')
// 配置 Git 用户身份(特别是在 CI 环境中)
try {
// 检查是否已配置用户身份
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git config user.name`
} catch {
// 如果没有配置,则设置默认的 CI 用户身份
logger.main.info('🔧 配置 Git 用户身份CI 环境)...')
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git config user.email "ci@afilmory.local"`
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git config user.name "Afilmory CI"`
}
// 检查是否有变更
const status = await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git status --porcelain`
if (!status.stdout.trim()) {
logger.main.info('💡 没有变更需要推送')
return false
}
logger.main.info('📋 检测到以下变更:')
logger.main.info(status.stdout)
// 配置 git 凭据
const repoUrl = builderConfig.repo.url
const { token } = builderConfig.repo
// 解析仓库 URL添加 token
let authenticatedUrl = repoUrl
if (repoUrl.startsWith('https://github.com/')) {
const urlWithoutProtocol = repoUrl.replace('https://', '')
authenticatedUrl = `https://${token}@${urlWithoutProtocol}`
}
// 设置远程仓库 URL包含 token
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git remote set-url origin ${authenticatedUrl}`
// 添加所有变更
await $({
cwd: assetsGitDir,
stdio: 'inherit',
})`git add .`
// 提交变更
const commitMessage = `chore: update photos-manifest.json and thumbnails - ${new Date().toISOString()}`
await $({
cwd: assetsGitDir,
stdio: 'inherit',
})`git commit -m ${commitMessage}`
// 推送到远程仓库
await $({
cwd: assetsGitDir,
stdio: 'inherit',
})`git push origin HEAD`
logger.main.success('✅ 成功推送更新到远程仓库')
return true
} catch (error) {
logger.main.error('❌ 推送到远程仓库失败:', error)
return false
}
}
async function main() {
// 检查是否作为 cluster worker 运行
if (
@@ -129,99 +22,10 @@ async function main() {
return
}
// 如果配置了远程仓库,则使用远程仓库
if (builderConfig.repo.enable) {
// 拉取远程仓库
logger.main.info('🔄 同步远程仓库...')
// 解析仓库 URL添加 token
let repoUrl = builderConfig.repo.url
const { token } = builderConfig.repo
if (token && repoUrl.startsWith('https://github.com/')) {
const urlWithoutProtocol = repoUrl.replace('https://', '')
repoUrl = `https://${token}@${urlWithoutProtocol}`
}
const hasExist = existsSync(path.resolve(workdir, 'assets-git'))
if (!hasExist) {
logger.main.info('📥 克隆远程仓库...')
await $({
cwd: workdir,
stdio: 'inherit',
})`git clone ${repoUrl} assets-git`
} else {
logger.main.info('🔄 拉取远程仓库更新...')
try {
await $({
cwd: path.resolve(workdir, 'assets-git'),
stdio: 'inherit',
})`git pull --rebase`
} catch {
logger.main.warn('⚠️ git pull 失败,尝试重置远程仓库...')
logger.main.info('🗑️ 删除现有仓库目录...')
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf assets-git`
logger.main.info('📥 重新克隆远程仓库...')
await $({
cwd: workdir,
stdio: 'inherit',
})`git clone ${repoUrl} assets-git`
}
}
// 确保远程仓库有必要的目录和文件
const assetsGitDir = path.resolve(workdir, 'assets-git')
const thumbnailsSourceDir = path.resolve(assetsGitDir, 'thumbnails')
const manifestSourcePath = path.resolve(
assetsGitDir,
'photos-manifest.json',
)
// 创建 thumbnails 目录(如果不存在)
if (!existsSync(thumbnailsSourceDir)) {
logger.main.info('📁 创建 thumbnails 目录...')
await $({ cwd: assetsGitDir, stdio: 'inherit' })`mkdir -p thumbnails`
}
// 创建空的 manifest 文件(如果不存在)
if (!existsSync(manifestSourcePath)) {
logger.main.info('📄 创建初始 manifest 文件...')
const { CURRENT_MANIFEST_VERSION } = await import('./manifest/version.js')
const initial = JSON.stringify(
{ version: CURRENT_MANIFEST_VERSION, data: [] },
null,
2,
)
await fs.writeFile(manifestSourcePath, initial)
}
// 删除 public/thumbnails 目录,并建立软连接到 assets-git/thumbnails
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
if (existsSync(thumbnailsDir)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
}
await $({
cwd: workdir,
stdio: 'inherit',
})`ln -s ${thumbnailsSourceDir} ${thumbnailsDir}`
// 删除 src/data/photos-manifest.json并建立软连接到 assets-git/photos-manifest.json
const photosManifestPath = path.resolve(
workdir,
'src',
'data',
'photos-manifest.json',
)
if (existsSync(photosManifestPath)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -f ${photosManifestPath}`
}
await $({
cwd: workdir,
stdio: 'inherit',
})`ln -s ${manifestSourcePath} ${photosManifestPath}`
logger.main.success('✅ 远程仓库同步完成')
}
const builderConfig = await loadBuilderConfig({
cwd: join(fileURLToPath(import.meta.url), '../../../..'),
})
const cliBuilder = new AfilmoryBuilder(builderConfig)
process.title = 'photo-gallery-builder-main'
// 解析命令行参数
@@ -339,23 +143,13 @@ async function main() {
environmentCheck()
// 启动构建过程
const buildResult = await cliBuilder.buildManifest({
await cliBuilder.buildManifest({
isForceMode,
isForceManifest,
isForceThumbnails,
concurrencyLimit,
})
// 如果启用了远程仓库,在构建完成后推送更新
if (builderConfig.repo.enable) {
if (buildResult.hasUpdates) {
logger.main.info('🔄 检测到更新,推送到远程仓库...')
await pushManifestToRemoteRepo()
} else {
logger.main.info('💡 没有更新需要推送到远程仓库')
}
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(0)
}

View File

@@ -0,0 +1,58 @@
import os from 'node:os'
import type { BuilderConfig } from '../types/config.js'
export function createDefaultBuilderConfig(): BuilderConfig {
return {
repo: {
enable: false,
url: process.env.BUILDER_REPO_URL || '',
token: process.env.GIT_TOKEN,
},
storage: {
provider: 's3',
bucket: process.env.S3_BUCKET_NAME,
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
prefix: process.env.S3_PREFIX,
customDomain: process.env.S3_CUSTOM_DOMAIN,
excludeRegex: process.env.S3_EXCLUDE_REGEX,
maxFileLimit: process.env.S3_MAX_FILE_LIMIT
? Number.parseInt(process.env.S3_MAX_FILE_LIMIT, 10)
: 1000,
keepAlive: true,
maxSockets: 64,
connectionTimeoutMs: 5_000,
socketTimeoutMs: 30_000,
requestTimeoutMs: 20_000,
idleTimeoutMs: 10_000,
totalTimeoutMs: 60_000,
retryMode: 'standard',
maxAttempts: 3,
downloadConcurrency: 16,
},
options: {
defaultConcurrency: 10,
enableLivePhotoDetection: true,
showProgress: true,
showDetailedStats: true,
digestSuffixLength: 0,
},
logging: {
verbose: false,
level: 'info',
outputToFile: false,
},
performance: {
worker: {
workerCount: os.cpus().length * 2,
timeout: 30_000,
useClusterMode: true,
workerConcurrency: 2,
},
},
plugins: [],
}
}

View File

@@ -0,0 +1,65 @@
import { loadConfig } from 'c12'
import consola from 'consola'
import { merge } from 'es-toolkit'
import type { BuilderConfig, BuilderConfigInput } from '../types/config.js'
import { clone } from '../utils/clone.js'
import { createDefaultBuilderConfig } from './defaults.js'
export interface LoadBuilderConfigOptions {
cwd?: string
configFile?: string
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
if (input.storage) {
merged.storage = input.storage as BuilderConfig['storage']
}
if (Array.isArray(input.plugins)) {
merged.plugins = [...input.plugins]
} else if (!Array.isArray(merged.plugins)) {
merged.plugins = []
}
return merged
}
export async function loadBuilderConfig(
options: LoadBuilderConfigOptions = {},
): Promise<BuilderConfig> {
const defaults = options.defaults ?? createDefaultBuilderConfig()
const result = await loadConfig<BuilderConfigInput>({
name: 'builder',
cwd: options.cwd ?? process.cwd(),
configFile: options.configFile,
rcFile: false,
dotenv: false,
})
const userConfig = result.config ?? {}
const config = normalizeBuilderConfig(defaults, userConfig)
if (process.env.DEBUG === '1') {
const logger = consola.withTag('CONFIG')
logger.info('Using builder config from', result.configFile ?? 'defaults')
logger.info(config)
}
return config
}

View File

@@ -1,6 +1,9 @@
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 type { LoadBuilderConfigOptions } from './config/index.js'
export { defineBuilderConfig, loadBuilderConfig } from './config/index.js'
export type {
PhotoProcessingContext,
ProcessedImageData,
@@ -12,6 +15,28 @@ export {
processPhotoWithPipeline,
} from './photo/image-pipeline.js'
export type { PhotoProcessorOptions } from './photo/processor.js'
export type { GitHubRepoSyncPluginOptions } from './plugins/github-repo-sync.js'
export {
createGitHubRepoSyncPlugin,
default as githubRepoSyncPlugin,
} from './plugins/github-repo-sync.js'
export type { EagleStoragePluginOptions } from './plugins/storage/eagle.js'
export { default as eagleStoragePlugin } from './plugins/storage/eagle.js'
export type { GitHubStoragePluginOptions } from './plugins/storage/github.js'
export { default as githubStoragePlugin } from './plugins/storage/github.js'
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 {
BuilderPlugin,
BuilderPluginConfigEntry,
BuilderPluginEvent,
BuilderPluginEventPayloads,
BuilderPluginHookContext,
BuilderPluginHooks,
BuilderPluginReference,
} from './plugins/types.js'
export type {
ProgressCallback,
ScanProgress,
@@ -20,7 +45,7 @@ export type {
StorageProvider,
} from './storage/index.js'
export { StorageFactory, StorageManager } from './storage/index.js'
export type { BuilderConfig } from './types/config.js'
export type { BuilderConfig, BuilderConfigInput } from './types/config.js'
export type {
AfilmoryManifest,
CameraInfo,

View File

@@ -5,14 +5,15 @@ import { compressUint8Array } from '@afilmory/utils'
import type { _Object } from '@aws-sdk/client-s3'
import sharp from 'sharp'
import type { AfilmoryBuilder } from '../builder/builder.js'
import type { AfilmoryBuilder, BuilderOptions } from '../builder/builder.js'
import {
convertBmpToJpegSharpInstance,
getImageMetadataWithSharp,
isBitmap,
preprocessImageBuffer,
} from '../image/processor.js'
import type { PhotoManifestItem } from '../types/photo.js'
import type { PluginRunState } from '../plugins/manager.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import { shouldProcessPhoto } from './cache-manager.js'
import {
processExifData,
@@ -36,6 +37,7 @@ export interface PhotoProcessingContext {
existingItem: PhotoManifestItem | undefined
livePhotoMap: Map<string, _Object>
options: PhotoProcessorOptions
pluginData: Record<string, unknown>
}
/**
@@ -255,15 +257,22 @@ export async function executePhotoProcessingPipeline(
export async function processPhotoWithPipeline(
context: PhotoProcessingContext,
builder: AfilmoryBuilder,
runtime: { runState: PluginRunState; builderOptions: BuilderOptions },
): Promise<{
item: PhotoManifestItem | null
type: 'new' | 'processed' | 'skipped' | 'failed'
pluginData: Record<string, unknown>
}> {
const { photoKey, existingItem, obj, options } = context
const loggers = getGlobalLoggers()
const photoId = await generatePhotoId(photoKey, builder)
await builder.emitPluginEvent(runtime.runState, 'beforePhotoProcess', {
options: runtime.builderOptions,
context,
})
// 检查是否需要处理
const { shouldProcess, reason } = await shouldProcessPhoto(
photoId,
@@ -274,7 +283,17 @@ export async function processPhotoWithPipeline(
if (!shouldProcess) {
loggers.image.info(`⏭️ 跳过处理 (${reason}): ${photoKey}`)
return { item: existingItem!, type: 'skipped' }
const result = {
item: existingItem ?? null,
type: 'skipped' as const,
pluginData: context.pluginData,
}
await builder.emitPluginEvent(runtime.runState, 'afterPhotoProcess', {
options: runtime.builderOptions,
context,
result,
})
return result
}
// 记录处理原因
@@ -285,15 +304,36 @@ export async function processPhotoWithPipeline(
loggers.image.info(`🔄 更新照片 (${reason})${photoKey}`)
}
// 执行处理管道
const processedItem = await executePhotoProcessingPipeline(context, builder)
let processedItem: PhotoManifestItem | null = null
let resultType: ProcessPhotoResult['type'] = isNewPhoto ? 'new' : 'processed'
if (!processedItem) {
return { item: null, type: 'failed' }
try {
processedItem = await executePhotoProcessingPipeline(context, builder)
if (!processedItem) {
resultType = 'failed'
}
} catch (error) {
await builder.emitPluginEvent(runtime.runState, 'photoProcessError', {
options: runtime.builderOptions,
context,
error,
})
loggers.image.error(`❌ 处理过程中发生异常:${photoKey}`, error)
processedItem = null
resultType = 'failed'
}
return {
const result = {
item: processedItem,
type: isNewPhoto ? 'new' : 'processed',
type: resultType,
pluginData: context.pluginData,
}
await builder.emitPluginEvent(runtime.runState, 'afterPhotoProcess', {
options: runtime.builderOptions,
context,
result,
})
return result
}

View File

@@ -1,7 +1,8 @@
import type { _Object } from '@aws-sdk/client-s3'
import type { AfilmoryBuilder } from '../builder/builder.js'
import type { AfilmoryBuilder, BuilderOptions } from '../builder/builder.js'
import { logger } from '../logger/index.js'
import type { PluginRunState } from '../plugins/manager.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import type { PhotoProcessingContext } from './image-pipeline.js'
import { processPhotoWithPipeline } from './image-pipeline.js'
@@ -26,6 +27,10 @@ export async function processPhoto(
livePhotoMap: Map<string, _Object>,
options: PhotoProcessorOptions,
builder: AfilmoryBuilder,
pluginRuntime: {
runState: PluginRunState
builderOptions: BuilderOptions
},
): Promise<ProcessPhotoResult> {
const key = obj.Key
if (!key) {
@@ -48,8 +53,9 @@ export async function processPhoto(
existingItem,
livePhotoMap,
options,
pluginData: {},
}
// 使用处理管道
return await processPhotoWithPipeline(context, builder)
return await processPhotoWithPipeline(context, builder, pluginRuntime)
}

View File

@@ -0,0 +1,244 @@
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import { $ } from 'execa'
import { workdir } from '../path.js'
import type { BuilderPlugin } from './types.js'
const RUN_SHARED_ASSETS_DIR = 'assetsGitDir'
export interface GitHubRepoSyncPluginOptions {
autoPush?: boolean
}
export default function githubRepoSyncPlugin(
options: GitHubRepoSyncPluginOptions = {},
): BuilderPlugin {
const autoPush = options.autoPush ?? true
return {
name: 'afilmory:github-repo-sync',
hooks: {
beforeBuild: async (context) => {
if (!context.config.repo.enable) {
return
}
const { logger } = context
const { repo } = context.config
if (!repo.url) {
logger.main.warn('⚠️ 未配置远程仓库地址,跳过同步')
return
}
const assetsGitDir = path.resolve(workdir, 'assets-git')
context.runShared.set(RUN_SHARED_ASSETS_DIR, assetsGitDir)
logger.main.info('🔄 同步远程仓库...')
const repoUrl = buildAuthenticatedRepoUrl(repo.url, repo.token)
if (!existsSync(assetsGitDir)) {
logger.main.info('📥 克隆远程仓库...')
await $({
cwd: workdir,
stdio: 'inherit',
})`git clone ${repoUrl} assets-git`
} else {
logger.main.info('🔄 拉取远程仓库更新...')
try {
await $({ cwd: assetsGitDir, stdio: 'inherit' })`git pull --rebase`
} catch {
logger.main.warn('⚠️ git pull 失败,尝试重新克隆远程仓库...')
logger.main.info('🗑️ 删除现有仓库目录...')
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf assets-git`
logger.main.info('📥 重新克隆远程仓库...')
await $({
cwd: workdir,
stdio: 'inherit',
})`git clone ${repoUrl} assets-git`
}
}
await prepareRepositoryLayout({ assetsGitDir, logger })
logger.main.success('✅ 远程仓库同步完成')
},
afterBuild: async (context) => {
if (!autoPush || !context.config.repo.enable) {
return
}
const { result } = context.payload
const assetsGitDir = context.runShared.get(RUN_SHARED_ASSETS_DIR) as
| string
| undefined
if (!assetsGitDir) {
context.logger.main.warn('⚠️ 未找到仓库目录,跳过推送')
return
}
if (!result.hasUpdates) {
context.logger.main.info('💡 没有更新需要推送到远程仓库')
return
}
await pushUpdatesToRemoteRepo({
assetsGitDir,
logger: context.logger,
repoConfig: context.config.repo,
})
},
},
}
}
interface PrepareRepositoryLayoutOptions {
assetsGitDir: string
logger: typeof import('../logger/index.js').logger
}
async function prepareRepositoryLayout({
assetsGitDir,
logger,
}: PrepareRepositoryLayoutOptions): Promise<void> {
const thumbnailsSourceDir = path.resolve(assetsGitDir, 'thumbnails')
const manifestSourcePath = path.resolve(assetsGitDir, 'photos-manifest.json')
if (!existsSync(thumbnailsSourceDir)) {
logger.main.info('📁 创建 thumbnails 目录...')
await $({ cwd: assetsGitDir, stdio: 'inherit' })`mkdir -p thumbnails`
}
if (!existsSync(manifestSourcePath)) {
logger.main.info('📄 创建初始 manifest 文件...')
const { CURRENT_MANIFEST_VERSION } = await import('../manifest/version.js')
const initial = JSON.stringify(
{ version: CURRENT_MANIFEST_VERSION, data: [] },
null,
2,
)
await fs.writeFile(manifestSourcePath, initial)
}
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
if (existsSync(thumbnailsDir)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
}
await $({
cwd: workdir,
stdio: 'inherit',
})`ln -s ${thumbnailsSourceDir} ${thumbnailsDir}`
const photosManifestPath = path.resolve(
workdir,
'src',
'data',
'photos-manifest.json',
)
if (existsSync(photosManifestPath)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -f ${photosManifestPath}`
}
await $({
cwd: workdir,
stdio: 'inherit',
})`ln -s ${manifestSourcePath} ${photosManifestPath}`
}
interface PushRemoteOptions {
assetsGitDir: string
logger: typeof import('../logger/index.js').logger
repoConfig: {
enable: boolean
url: string
token?: string
}
}
async function pushUpdatesToRemoteRepo({
assetsGitDir,
logger,
repoConfig,
}: PushRemoteOptions): Promise<void> {
if (!repoConfig.url) {
return
}
if (!repoConfig.token) {
logger.main.warn('⚠️ 未提供 Git Token跳过推送到远程仓库')
return
}
logger.main.info('📤 开始推送更新到远程仓库...')
await ensureGitUserConfigured(assetsGitDir)
const status = await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git status --porcelain`
if (!status.stdout.trim()) {
logger.main.info('💡 没有变更需要推送')
return
}
logger.main.info('📋 检测到以下变更:')
logger.main.info(status.stdout)
const authenticatedUrl = buildAuthenticatedRepoUrl(
repoConfig.url,
repoConfig.token,
)
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git remote set-url origin ${authenticatedUrl}`
await $({ cwd: assetsGitDir, stdio: 'inherit' })`git add .`
const commitMessage = `chore: update photos-manifest.json and thumbnails - ${new Date().toISOString()}`
await $({
cwd: assetsGitDir,
stdio: 'inherit',
})`git commit -m ${commitMessage}`
await $({ cwd: assetsGitDir, stdio: 'inherit' })`git push origin HEAD`
logger.main.success('✅ 成功推送更新到远程仓库')
}
async function ensureGitUserConfigured(assetsGitDir: string): Promise<void> {
try {
await $({ cwd: assetsGitDir, stdio: 'pipe' })`git config user.name`
} catch {
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git config user.email "ci@afilmory.local"`
await $({
cwd: assetsGitDir,
stdio: 'pipe',
})`git config user.name "Afilmory CI"`
}
}
function buildAuthenticatedRepoUrl(url: string, token?: string): string {
if (!token) return url
if (url.startsWith('https://github.com/')) {
const urlWithoutProtocol = url.replace('https://', '')
return `https://${token}@${urlWithoutProtocol}`
}
return url
}
export const plugin = githubRepoSyncPlugin
export function createGitHubRepoSyncPlugin(
options?: GitHubRepoSyncPluginOptions,
): BuilderPlugin {
return githubRepoSyncPlugin(options)
}

View File

@@ -0,0 +1,165 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import type {
BuilderPlugin,
BuilderPluginConfigEntry,
BuilderPluginHooks,
BuilderPluginReference,
} from './types.js'
import { isPluginReferenceObject } from './types.js'
const requireResolver = createRequire(import.meta.url)
export interface LoadedPluginDefinition {
name: string
hooks: BuilderPluginHooks
pluginOptions: unknown
}
interface NormalizedDescriptor {
specifier: string
name?: string
options?: unknown
}
function normalizeDescriptor(
ref: BuilderPluginReference,
): NormalizedDescriptor {
if (typeof ref === 'string') {
return { specifier: ref }
}
return {
specifier: ref.resolve,
name: ref.name,
options: ref.options,
}
}
function resolveSpecifier(
specifier: string,
baseDir: string,
): { resolvedPath: string } {
const isLocal =
specifier.startsWith('.') ||
specifier.startsWith('/') ||
specifier.startsWith('file:')
if (isLocal) {
const resolvedPath = specifier.startsWith('file:')
? specifier
: path.resolve(baseDir, specifier)
return { resolvedPath }
}
const resolvedPath = requireResolver.resolve(specifier, {
paths: [baseDir],
})
return { resolvedPath }
}
async function importModule(resolvedPath: string): Promise<unknown> {
if (resolvedPath.startsWith('file:')) {
return await import(resolvedPath)
}
const url = pathToFileURL(resolvedPath).href
return await import(url)
}
async function instantiatePlugin(
exportedValue: unknown,
options?: unknown,
): Promise<BuilderPlugin> {
const picked = [
(exportedValue as { default?: unknown })?.default,
(exportedValue as { plugin?: unknown }).plugin,
(exportedValue as { createPlugin?: unknown }).createPlugin,
exportedValue,
].find(Boolean)
if (typeof picked === 'function') {
const result = await Promise.resolve((picked as Function)(options))
if (!result || typeof result !== 'object') {
throw new Error('Plugin factory must return an object')
}
return result as BuilderPlugin
}
if (!picked || typeof picked !== 'object') {
throw new Error('Unsupported plugin export format')
}
return picked as BuilderPlugin
}
function normalizeHooks(plugin: BuilderPlugin): BuilderPluginHooks {
const hooks: BuilderPluginHooks = {}
if (plugin.hooks && typeof plugin.hooks === 'object') {
Object.assign(hooks, plugin.hooks)
}
const candidates = Object.entries(plugin).filter(
([key]) => key !== 'name' && key !== 'hooks',
) as Array<[string, unknown]>
for (const [key, value] of candidates) {
if (typeof value === 'function' && !(key in hooks)) {
;(hooks as Record<string, unknown>)[key] = value
}
}
return hooks
}
export async function loadPlugins(
entries: BuilderPluginConfigEntry[] = [],
options: { baseDir: string },
): Promise<LoadedPluginDefinition[]> {
if (entries.length === 0) return []
const { baseDir } = options
const results: LoadedPluginDefinition[] = []
for (const entry of entries) {
if (typeof entry === 'string' || isPluginReferenceObject(entry)) {
const descriptor = normalizeDescriptor(entry)
const { resolvedPath } = resolveSpecifier(descriptor.specifier, baseDir)
const mod = await importModule(resolvedPath)
const plugin = await instantiatePlugin(mod, descriptor.options)
const hooks = normalizeHooks(plugin)
const name =
plugin.name ||
descriptor.name ||
(descriptor.specifier.startsWith('.')
? path.basename(descriptor.specifier)
: descriptor.specifier)
results.push({
name,
hooks,
pluginOptions: descriptor.options,
})
continue
}
const plugin = await instantiatePlugin(entry)
const hooks = normalizeHooks(plugin)
const name = plugin.name || `inline-plugin-${results.length}`
results.push({
name,
hooks,
pluginOptions: undefined,
})
}
return results
}

View File

@@ -0,0 +1,122 @@
import type { AfilmoryBuilder } from '../builder/builder.js'
import { logger } from '../logger/index.js'
import { loadPlugins } from './loader.js'
import type {
BuilderPluginConfigEntry,
BuilderPluginEvent,
BuilderPluginEventPayloads,
BuilderPluginHookContext,
} from './types.js'
export type PluginRunState = Map<string, Map<string, unknown>>
export class PluginManager {
private readonly entries: BuilderPluginConfigEntry[]
private readonly baseDir: string
private plugins: Awaited<ReturnType<typeof loadPlugins>> = []
private loadPromise: Promise<void> | null = null
constructor(
entries: BuilderPluginConfigEntry[] = [],
options: { baseDir?: string } = {},
) {
this.entries = entries
this.baseDir = options.baseDir ?? process.cwd()
}
hasPlugins(): boolean {
return this.entries.length > 0
}
createRunState(): PluginRunState {
return new Map()
}
async ensureLoaded(builder: AfilmoryBuilder): Promise<void> {
if (this.plugins.length > 0 || this.loadPromise) {
await this.loadPromise
return
}
if (this.entries.length === 0) {
this.plugins = []
return
}
this.loadPromise = (async () => {
this.plugins = await loadPlugins(this.entries, {
baseDir: this.baseDir,
})
for (const plugin of this.plugins) {
const initHook = plugin.hooks.onInit
if (!initHook) continue
try {
await initHook({
builder,
config: builder.getConfig(),
logger,
registerStorageProvider:
builder.registerStorageProvider.bind(builder),
pluginOptions: plugin.pluginOptions,
})
} catch (error) {
logger.main.error(
`[Builder Plugin] 初始化插件 "${plugin.name}" 失败`,
error,
)
throw error
}
}
})()
await this.loadPromise
}
async emit<TEvent extends BuilderPluginEvent>(
builder: AfilmoryBuilder,
runState: PluginRunState,
event: TEvent,
payload: BuilderPluginEventPayloads[TEvent],
): Promise<void> {
if (this.plugins.length === 0) return
for (const plugin of this.plugins) {
const hook = plugin.hooks[event] as
| ((context: BuilderPluginHookContext<TEvent>) => void | Promise<void>)
| undefined
if (!hook) continue
const sharedKey = plugin.name
let shared = runState.get(sharedKey)
if (!shared) {
shared = new Map()
runState.set(sharedKey, shared)
}
const context: BuilderPluginHookContext<TEvent> = {
builder,
config: builder.getConfig(),
logger,
registerStorageProvider: builder.registerStorageProvider.bind(builder),
options: payload.options,
pluginName: plugin.name,
pluginOptions: plugin.pluginOptions,
runShared: shared,
event,
payload,
}
try {
await hook(context)
} catch (error) {
logger.main.error(
`[Builder Plugin] 插件 "${plugin.name}" 在 ${event} 钩子中抛出错误`,
error,
)
throw error
}
}
}
}

View File

@@ -0,0 +1,24 @@
import type { EagleConfig } from '../../storage/interfaces.js'
import { EagleStorageProvider } from '../../storage/providers/eagle-provider.js'
import type { BuilderPlugin } from '../types.js'
export interface EagleStoragePluginOptions {
provider?: string
}
export default function eagleStoragePlugin(
options: EagleStoragePluginOptions = {},
): BuilderPlugin {
const providerName = options.provider ?? 'eagle'
return {
name: `afilmory:storage:${providerName}`,
hooks: {
onInit: ({ registerStorageProvider }) => {
registerStorageProvider(providerName, (config) => {
return new EagleStorageProvider(config as EagleConfig)
})
},
},
}
}

View File

@@ -0,0 +1,24 @@
import type { GitHubConfig } from '../../storage/interfaces.js'
import { GitHubStorageProvider } from '../../storage/providers/github-provider.js'
import type { BuilderPlugin } from '../types.js'
export interface GitHubStoragePluginOptions {
provider?: string
}
export default function githubStoragePlugin(
options: GitHubStoragePluginOptions = {},
): BuilderPlugin {
const providerName = options.provider ?? 'github'
return {
name: `afilmory:storage:${providerName}`,
hooks: {
onInit: ({ registerStorageProvider }) => {
registerStorageProvider(providerName, (config) => {
return new GitHubStorageProvider(config as GitHubConfig)
})
},
},
}
}

View File

@@ -0,0 +1,24 @@
import type { LocalConfig } from '../../storage/interfaces.js'
import { LocalStorageProvider } from '../../storage/providers/local-provider.js'
import type { BuilderPlugin } from '../types.js'
export interface LocalStoragePluginOptions {
provider?: string
}
export default function localStoragePlugin(
options: LocalStoragePluginOptions = {},
): BuilderPlugin {
const providerName = options.provider ?? 'local'
return {
name: `afilmory:storage:${providerName}`,
hooks: {
onInit: ({ registerStorageProvider }) => {
registerStorageProvider(providerName, (config) => {
return new LocalStorageProvider(config as LocalConfig)
})
},
},
}
}

View File

@@ -0,0 +1,24 @@
import type { S3Config } from '../../storage/interfaces.js'
import { S3StorageProvider } from '../../storage/providers/s3-provider.js'
import type { BuilderPlugin } from '../types.js'
export interface S3StoragePluginOptions {
provider?: string
}
export default function s3StoragePlugin(
options: S3StoragePluginOptions = {},
): BuilderPlugin {
const providerName = options.provider ?? 's3'
return {
name: `afilmory:storage:${providerName}`,
hooks: {
onInit: ({ registerStorageProvider }) => {
registerStorageProvider(providerName, (config) => {
return new S3StorageProvider(config as S3Config)
})
},
},
}
}

View File

@@ -0,0 +1,192 @@
import type {
AfilmoryBuilder,
BuilderOptions,
BuilderResult,
} from '../builder/builder.js'
import type { Logger } from '../logger/index.js'
import type { PhotoProcessingContext } from '../photo/image-pipeline.js'
import type { PhotoProcessorOptions } from '../photo/processor.js'
import type { StorageObject } from '../storage/interfaces.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'
export type BuilderPluginReference =
| string
| {
resolve: string
/**
* Optional name override for the plugin. Falls back to the resolved name.
*/
name?: string
/**
* Arbitrary configuration passed to the plugin factory.
*/
options?: unknown
}
export type BuilderPluginConfigEntry = BuilderPluginReference | BuilderPlugin
export interface BuilderPluginInitContext {
builder: AfilmoryBuilder
config: BuilderConfig
logger: Logger
registerStorageProvider: AfilmoryBuilder['registerStorageProvider']
/**
* Options provided in the configuration for this plugin.
*/
pluginOptions: unknown
}
export interface BuilderPluginEventPayloads {
beforeBuild: {
options: BuilderOptions
}
beforePhotoProcess: {
options: BuilderOptions
context: PhotoProcessingContext
}
afterPhotoProcess: {
options: BuilderOptions
context: PhotoProcessingContext
result: {
type: ProcessPhotoResult['type']
item: PhotoManifestItem | null
pluginData: Record<string, unknown>
}
}
photoProcessError: {
options: BuilderOptions
context: PhotoProcessingContext
error: unknown
}
afterManifestLoad: {
options: BuilderOptions
manifest: AfilmoryManifest
manifestMap: Map<string, PhotoManifestItem>
}
afterAllFilesListed: {
options: BuilderOptions
allObjects: StorageObject[]
}
afterLivePhotoDetection: {
options: BuilderOptions
livePhotoMap: Map<string, StorageObject>
}
afterImagesListed: {
options: BuilderOptions
imageObjects: StorageObject[]
}
afterTasksPrepared: {
options: BuilderOptions
tasks: StorageObject[]
totalImages: number
}
beforeProcessTasks: {
options: BuilderOptions
tasks: StorageObject[]
processorOptions: PhotoProcessorOptions
mode: 'cluster' | 'worker'
concurrency: number
}
afterProcessTasks: {
options: BuilderOptions
tasks: StorageObject[]
results: ProcessPhotoResult[]
manifest: PhotoManifestItem[]
stats: {
newCount: number
processedCount: number
skippedCount: number
}
}
afterCleanup: {
options: BuilderOptions
manifest: PhotoManifestItem[]
deletedCount: number
}
beforeAddManifestItem: {
options: BuilderOptions
item: PhotoManifestItem
pluginData: Record<string, unknown>
resultType: ProcessPhotoResult['type']
}
beforeSaveManifest: {
options: BuilderOptions
manifest: PhotoManifestItem[]
cameras: CameraInfo[]
lenses: LensInfo[]
}
afterSaveManifest: {
options: BuilderOptions
manifest: PhotoManifestItem[]
cameras: CameraInfo[]
lenses: LensInfo[]
}
afterBuild: {
options: BuilderOptions
result: BuilderResult
manifest: PhotoManifestItem[]
}
onError: {
options: BuilderOptions
error: unknown
}
}
export type BuilderPluginEvent = keyof BuilderPluginEventPayloads
export interface BuilderPluginHookContext<TEvent extends BuilderPluginEvent> {
builder: AfilmoryBuilder
config: BuilderConfig
logger: Logger
options: BuilderOptions
registerStorageProvider: AfilmoryBuilder['registerStorageProvider']
/**
* Name of the plugin handling the current hook.
*/
pluginName: string
/**
* Options associated with the plugin, if any.
*/
pluginOptions: unknown
/**
* A mutable map scoped to the current build run, allowing plugins
* to persist information between lifecycle hooks.
*/
runShared: Map<string, unknown>
event: TEvent
payload: BuilderPluginEventPayloads[TEvent]
}
export type BuilderPluginHook<TEvent extends BuilderPluginEvent> = (
context: BuilderPluginHookContext<TEvent>,
) => void | Promise<void>
export type BuilderPluginLifecycleHooks = Partial<{
[Event in BuilderPluginEvent]: BuilderPluginHook<Event>
}>
export interface BuilderPluginHooks extends BuilderPluginLifecycleHooks {
onInit?: (context: BuilderPluginInitContext) => void | Promise<void>
}
export interface BuilderPlugin {
name?: string
hooks?: BuilderPluginHooks
}
export type BuilderPluginFactory =
| BuilderPlugin
| (() => BuilderPlugin | Promise<BuilderPlugin>)
| ((options: unknown) => BuilderPlugin | Promise<BuilderPlugin>)
export function isPluginReferenceObject(
value: BuilderPluginConfigEntry,
): value is Exclude<BuilderPluginReference, string> {
return typeof value === 'object' && value !== null && 'resolve' in value
}

View File

@@ -1,7 +1,9 @@
import process from 'node:process'
import { deserialize } from 'node:v8'
import type { BuilderOptions } from './builder/builder.js'
import { AfilmoryBuilder } from './builder/builder.js'
import type { PluginRunState } from './plugins/manager.js'
import type { StorageObject } from './storage/interfaces'
import type { BuilderConfig } from './types/config.js'
import type { PhotoManifestItem } from './types/photo'
@@ -40,6 +42,7 @@ export async function runAsWorker() {
let existingManifestMap: Map<string, PhotoManifestItem>
let livePhotoMap: Map<string, StorageObject>
let builder: AfilmoryBuilder
let pluginRunState: PluginRunState
// 初始化函数,从主进程接收共享数据
const initializeWorker = async (
@@ -56,6 +59,8 @@ export async function runAsWorker() {
existingManifestMap = sharedData.existingManifestMap
livePhotoMap = sharedData.livePhotoMap
builder = new AfilmoryBuilder(sharedData.builderConfig)
await builder.ensurePluginsReady()
pluginRunState = builder.createPluginRunState()
isInitialized = true
}
@@ -104,6 +109,13 @@ export async function runAsWorker() {
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
}
const builderOptions: BuilderOptions = {
isForceMode: processorOptions.isForceMode,
isForceManifest: processorOptions.isForceManifest,
isForceThumbnails: processorOptions.isForceThumbnails,
concurrencyLimit: undefined,
}
// 处理照片
const result = await processPhoto(
legacyObj,
@@ -114,6 +126,10 @@ export async function runAsWorker() {
legacyLivePhotoMap,
processorOptions,
builder,
{
runState: pluginRunState,
builderOptions,
},
)
// 发送结果回主进程
@@ -150,6 +166,17 @@ export async function runAsWorker() {
const results: TaskResult[] = []
const taskPromises: Promise<void>[] = []
const batchProcessorOptions = {
isForceMode: process.env.FORCE_MODE === 'true',
isForceManifest: process.env.FORCE_MANIFEST === 'true',
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
}
const batchBuilderOptions: BuilderOptions = {
isForceMode: batchProcessorOptions.isForceMode,
isForceManifest: batchProcessorOptions.isForceManifest,
isForceThumbnails: batchProcessorOptions.isForceThumbnails,
concurrencyLimit: undefined,
}
// 创建所有任务的并发执行 Promise
for (const task of message.tasks) {
@@ -185,12 +212,12 @@ export async function runAsWorker() {
imageObjects.length,
existingManifestMap,
legacyLivePhotoMap,
{
isForceMode: process.env.FORCE_MODE === 'true',
isForceManifest: process.env.FORCE_MANIFEST === 'true',
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
},
batchProcessorOptions,
builder,
{
runState: pluginRunState,
builderOptions: batchBuilderOptions,
},
)
// 添加成功结果

View File

@@ -1,29 +1,40 @@
import type { StorageConfig, StorageProvider } from './interfaces'
import { EagleStorageProvider } from './providers/eagle-provider.js'
import { GitHubStorageProvider } from './providers/github-provider.js'
import { LocalStorageProvider } from './providers/local-provider.js'
import { S3StorageProvider } from './providers/s3-provider.js'
import type { StorageConfig, StorageProvider } from './interfaces.js'
export type StorageProviderFactory<T extends StorageConfig = StorageConfig> = (
config: T,
) => StorageProvider
export class StorageFactory {
private static providers = new Map<string, StorageProviderFactory>()
/**
* Register or override a storage provider factory.
*/
static registerProvider(
provider: string,
factory: StorageProviderFactory,
): void {
StorageFactory.providers.set(provider, factory)
}
/**
* 根据配置创建存储提供商实例
* @param config 存储配置
* @returns 存储提供商实例
*/
static createProvider(config: StorageConfig): StorageProvider {
switch (config.provider) {
case 's3': {
return new S3StorageProvider(config)
}
case 'github': {
return new GitHubStorageProvider(config)
}
case 'eagle': {
return new EagleStorageProvider(config)
}
case 'local': {
return new LocalStorageProvider(config)
}
const factory = StorageFactory.providers.get(config.provider)
if (!factory) {
throw new Error(
`Unsupported storage provider: ${config.provider as string}`,
)
}
return factory(config)
}
static getRegisteredProviders(): string[] {
return Array.from(StorageFactory.providers.keys())
}
}

View File

@@ -8,6 +8,7 @@ export type {
} from './interfaces.js'
// 导出工厂类
export type { StorageProviderFactory } from './factory.js'
export { StorageFactory } from './factory.js'
// 导出管理器

View File

@@ -1,3 +1,4 @@
import type { BuilderPluginConfigEntry } from '../plugins/types.js'
import type { StorageConfig } from '../storage/interfaces.js'
export interface BuilderConfig {
@@ -29,4 +30,13 @@ export interface BuilderConfig {
workerCount: number
}
}
plugins?: BuilderPluginConfigEntry[]
}
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
export type BuilderConfigInput = DeepPartial<Omit<BuilderConfig, 'plugins'>> & {
plugins?: BuilderPluginConfigEntry[]
}

View File

@@ -63,6 +63,7 @@ export interface PhotoManifestItem extends PhotoInfo {
export interface ProcessPhotoResult {
item: PhotoManifestItem | null
type: 'processed' | 'skipped' | 'new' | 'failed'
pluginData?: Record<string, unknown>
}
export interface PickedExif {

View File

@@ -0,0 +1,15 @@
import { deserialize as v8Deserialize, serialize as v8Serialize } from 'node:v8'
export function clone<T>(value: T): T {
const maybeStructuredClone = (
globalThis as typeof globalThis & {
structuredClone?: <U>(input: U) => U
}
).structuredClone
if (typeof maybeStructuredClone === 'function') {
return maybeStructuredClone(value)
}
return v8Deserialize(v8Serialize(value))
}

View File

@@ -27,9 +27,6 @@
"@pkg": [
"./package.json"
],
"@builder": [
"../../builder.config.ts"
],
"@env": [
"../../env.ts"
]

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: './src/index.ts',
outDir: './dist',
format: 'esm',
clean: true,
dts: true,
})

View File

@@ -2,7 +2,7 @@
title: Docker
description: Guide to deploying Afilmory via Docker.
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-08-31T11:08:35+08:00
lastModified: 2025-10-28T19:48:05+08:00
---
# Docker Deployment
@@ -13,7 +13,6 @@ lastModified: 2025-08-31T11:08:35+08:00
Fork https://github.com/Afilmory/docker, customize configuration and build image.
## 🚀 Quick Start
Using Docker to deploy Afilmory is a convenient and efficient way to manage your photo gallery application. This guide will walk you through the steps to set up and run Afilmory using Docker.
@@ -33,7 +32,7 @@ Before building your Docker image, you'll need to configure the following files:
```json
{
"name": "Your Photo Gallery",
"title": "Your Photo Gallery",
"title": "Your Photo Gallery",
"description": "Capturing beautiful moments in life",
"url": "https://your-domain.com",
"accentColor": "#fb7185",
@@ -51,28 +50,30 @@ Before building your Docker image, you'll need to configure the following files:
}
```
**`builder.config.json`**
**`builder.config.ts`**
```json
{
"repo": {
"enable": false,
"url": "https://github.com/username/gallery-public"
```ts
import { defineBuilderConfig } from '@afilmory/builder'
export default defineBuilderConfig(() => ({
repo: {
enable: false,
url: 'https://github.com/username/gallery-public',
},
"storage": {
"provider": "s3",
"bucket": "your-photos-bucket",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "cdn.yourdomain.com"
storage: {
provider: 's3',
bucket: 'your-photos-bucket',
region: 'us-east-1',
prefix: 'photos/',
customDomain: 'cdn.yourdomain.com',
},
"performance": {
"worker": {
"enabled": true,
"maxWorkers": 4
}
}
}
performance: {
worker: {
enabled: true,
maxWorkers: 4,
},
},
}))
```
**`.env`**
@@ -119,7 +120,7 @@ RUN apk update && apk add --no-cache git perl
RUN git clone https://github.com/Afilmory/Afilmory --depth 1 .
COPY config.json ./
COPY builder.config.json ./
COPY builder.config.ts ./
COPY .env ./
ARG S3_ACCESS_KEY_ID
@@ -179,7 +180,7 @@ services:
- NODE_ENV=production
volumes:
- ./config.json:/app/config.json:ro
- ./builder.config.json:/app/builder.config.json:ro
- ./builder.config.ts:/app/builder.config.ts:ro
- ./.env:/app/.env:ro
depends_on:
- postgres
@@ -226,7 +227,7 @@ docker run -d \
-p 3000:3000 \
--env-file .env \
-v $(pwd)/config.json:/app/config.json:ro \
-v $(pwd)/builder.config.json:/app/builder.config.json:ro \
-v $(pwd)/builder.config.ts:/app/builder.config.ts:ro \
afilmory
```
@@ -234,9 +235,10 @@ docker run -d \
### Storage Providers
Afilmory supports multiple storage providers. Configure them in `builder.config.json`:
Afilmory supports multiple storage providers. Configure them in `builder.config.ts`:
**S3-Compatible Storage:**
```json
{
"storage": {
@@ -250,6 +252,7 @@ Afilmory supports multiple storage providers. Configure them in `builder.config.
```
**GitHub Storage:**
```json
{
"storage": {
@@ -280,4 +283,3 @@ For optimal performance in Docker environments:
}
}
```

View File

@@ -1,13 +1,17 @@
---
title: Overview
createdAt: 2025-07-20T22:35:03+08:00
lastModified: 2025-07-20T22:35:03+08:00
lastModified: 2025-10-28T19:48:05+08:00
---
# Overview
<p align="center">
<img src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true" alt="Afilmory" width="100%" />
<img
src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true"
alt="Afilmory"
width="100%"
/>
</p>
Afilmory (/əˈfɪlməri/, "uh-FIL-muh-ree") is a term created for personal photography websites, blending Auto Focus (AF), aperture (light control), film (vintage medium), and memory (captured moments).
@@ -169,7 +173,7 @@ pnpm build
### Notes
- Ensure your S3 bucket already contains photo files
- If using remote repository, configure `builder.config.json` first
- If using remote repository, configure `builder.config.ts` first
## 🔧 Advanced Usage
@@ -214,4 +218,4 @@ MIT License © 2025 Innei
- [Personal Website](https://innei.in)
- [GitHub](https://github.com/innei)
If this project helps you, please give it a ⭐️ Star for support!
If this project helps you, please give it a ⭐️ Star for support!

View File

@@ -2,7 +2,7 @@
title: Storage providers
description: Afilmory can work with multiple storage providers, including S3, Git, Eagle, and local file system
createdAt: 2025-08-12T15:09:08+08:00
lastModified: 2025-10-23T18:31:41+08:00
lastModified: 2025-10-28T19:48:05+08:00
---
# Storage Providers
@@ -15,18 +15,20 @@ Afilmory's flexible storage architecture allows you to store your photos across
S3-compatible storage is the recommended option for production deployments, offering scalability, CDN integration, and reliable performance.
**Configuration in `builder.config.json`:**
**Configuration in `builder.config.ts`:**
```json
{
"storage": {
"provider": "s3",
"bucket": "your-photos-bucket",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "cdn.yourdomain.com"
}
}
```ts
import { defineBuilderConfig } from '@afilmory/builder'
export default defineBuilderConfig(() => ({
storage: {
provider: 's3',
bucket: 'your-photos-bucket',
region: 'us-east-1',
prefix: 'photos/',
customDomain: 'cdn.yourdomain.com',
},
}))
```
**Environment Variables (`.env`):**
@@ -41,6 +43,7 @@ S3_SECRET_ACCESS_KEY=your_secret_access_key
GitHub storage leverages Git repositories for photo storage, ideal for static sites, small galleries, or when you want version control for your photos.
**Features:**
- ✅ Free storage space (GitHub repository limit 1GB)
- ✅ Global CDN support via raw.githubusercontent.com
- ✅ Version control for your photos
@@ -48,19 +51,19 @@ GitHub storage leverages Git repositories for photo storage, ideal for static si
- ⚠️ GitHub API rate limits apply
- ⚠️ Not suitable for large files or frequent updates
**Configuration in `builder.config.json`:**
**Configuration in `builder.config.ts`:**
```json
{
"storage": {
"provider": "github",
"owner": "your-username",
"repo": "photo-storage",
"branch": "main",
"path": "photos",
"useRawUrl": true
}
}
```ts
export default defineBuilderConfig(() => ({
storage: {
provider: 'github',
owner: 'your-username',
repo: 'photo-storage',
branch: 'main',
path: 'photos',
useRawUrl: true,
},
}))
```
**Environment Variables:**
@@ -72,6 +75,7 @@ GIT_TOKEN=ghp_your_github_personal_access_token
**Setup Steps:**
1. **Create GitHub Repository**
```bash
git clone https://github.com/your-username/photo-gallery.git
cd photo-gallery
@@ -92,30 +96,29 @@ GIT_TOKEN=ghp_your_github_personal_access_token
Eagle storage integrates directly with an existing Eagle 4 desktop library, making it ideal for teams that already curate assets in Eagle and want to publish selected photos without additional uploads.
**Features:**
- ✅ No upload step: reads directly from the Eagle library
- ✅ Flexible filtering: include or exclude by folder (with optional subfolders) and tags
- ✅ Automatic publishing: copies originals to a public directory on demand
- ⚠️ Requires absolute paths for `libraryPath` and `distPath`
- ⚠️ Only Eagle 4.x is validated; other versions may require testing
**Configuration in `builder.config.json`:**
**Configuration in `builder.config.ts`:**
```json
{
"storage": {
"provider": "eagle",
"libraryPath": "/Users/alice/Pictures/Eagle.library",
"distPath": "/Users/alice/workspaces/afilmory/apps/web/public/originals",
"baseUrl": "/originals/",
"include": [
{ "type": "folder", "name": "Published", "includeSubfolder": true },
{ "type": "tag", "name": "Featured" }
```ts
export default defineBuilderConfig(() => ({
storage: {
provider: 'eagle',
libraryPath: '/Users/alice/Pictures/Eagle.library',
distPath: '/Users/alice/workspaces/afilmory/apps/web/public/originals',
baseUrl: '/originals/',
include: [
{ type: 'folder', name: 'Published', includeSubfolder: true },
{ type: 'tag', name: 'Featured' },
],
"exclude": [
{ "type": "tag", "name": "Private" }
]
}
}
exclude: [{ type: 'tag', name: 'Private' }],
},
}))
```
**Setup Steps:**
@@ -126,6 +129,7 @@ Eagle storage integrates directly with an existing Eagle 4 desktop library, maki
4. **Refine filters (optional)**: use `include` and `exclude` rules to control which assets are exported.
**Rule Notes:**
- Leaving `include` empty processes every supported image in the library.
- `exclude` overrides inclusion rules when both match a single asset.
- `folder` rules use folder names (not full paths); enabling `includeSubfolder` recursively matches children.
@@ -136,6 +140,7 @@ Eagle storage integrates directly with an existing Eagle 4 desktop library, maki
Local storage is suitable for development, testing, or self-hosted deployments where photos are stored on the same server.
**Features:**
- ✅ No external dependencies
- ✅ Fast access speeds
- ✅ Complete private control
@@ -144,34 +149,35 @@ Local storage is suitable for development, testing, or self-hosted deployments w
- ⚠️ Requires proper file system permissions
- ⚠️ Not suitable for distributed deployments
**Configuration in `builder.config.json`:**
**Configuration in `builder.config.ts`:**
Self-hosted original image (manually serve to `basePath`):
```json
{
"storage": {
"provider": "local",
"basePath": "./photos",
"baseUrl": "http://example.com:3000/photos/"
}
}
```ts
import { defineBuilderConfig } from '@afilmory/builder'
export default defineBuilderConfig(() => ({
storage: {
provider: 'local',
basePath: './photos',
baseUrl: 'http://example.com:3000/photos/',
},
}))
```
Hosted with frontend (serve via app static assets):
```json
{
"storage": {
"provider": "local",
"basePath": "./photos",
"distPath": "./apps/web/public/originals",
"baseUrl": "/originals/"
}
}
export default defineBuilderConfig(() => ({
storage: {
provider: 'local',
basePath: './photos',
distPath: './apps/web/public/originals',
baseUrl: '/originals/',
},
}))
```
Use when you want originals deployed alongside your frontends static assets. On initialization/build, files from `basePath` are copied to `distPath` (overwriting same-named files).
Use when you want originals deployed alongside your frontends static assets. On initialization/build, files from `basePath` are copied to `distPath` (overwriting same-named files).
## Photo Processing Workflow
@@ -273,13 +279,13 @@ For production, use S3-compatible storage with CDN:
### Storage Provider Comparison
| Feature | S3 | GitHub | Local |
|---------|----|----|--------|
| Storage Space | Pay-as-you-go | 1GB free | Depends on disk |
| CDN | Additional cost | Free global CDN | Manual setup |
| API Limits | Very high | Limited | None |
| Use Case | Production | Small projects, demos | Development, self-hosted |
| Setup Complexity | Medium | Simple | Minimal |
| Feature | S3 | GitHub | Local |
| ---------------- | --------------- | --------------------- | ------------------------ |
| Storage Space | Pay-as-you-go | 1GB free | Depends on disk |
| CDN | Additional cost | Free global CDN | Manual setup |
| API Limits | Very high | Limited | None |
| Use Case | Production | Small projects, demos | Development, self-hosted |
| Setup Complexity | Medium | Simple | Minimal |
## Security Considerations
@@ -318,4 +324,4 @@ S3_SECRET_ACCESS_KEY=prod_secret_key
# .env.development
S3_ACCESS_KEY_ID=dev_access_key
S3_SECRET_ACCESS_KEY=dev_secret_key
```
```

231
pnpm-lock.yaml generated
View File

@@ -105,6 +105,9 @@ importers:
lint-staged:
specifier: 16.2.6
version: 16.2.6
nbump:
specifier: 2.1.8
version: 2.1.8(conventional-commits-filter@5.0.0)(magicast@0.3.5)
opentype.js:
specifier: 1.3.4
version: 1.3.4
@@ -980,9 +983,6 @@ importers:
packages/builder:
dependencies:
'@afilmory/utils':
specifier: workspace:*
version: link:../utils
'@aws-sdk/client-s3':
specifier: 3.916.0
version: 3.916.0
@@ -998,6 +998,9 @@ importers:
blurhash:
specifier: 2.0.5
version: 2.0.5
c12:
specifier: ^1.11.2
version: 1.11.2(magicast@0.3.5)
dotenv-expand:
specifier: 'catalog:'
version: 12.0.3
@@ -1019,6 +1022,13 @@ importers:
thumbhash:
specifier: 0.1.1
version: 0.1.1
devDependencies:
'@afilmory/utils':
specifier: workspace:*
version: link:../utils
tsdown:
specifier: 0.15.9
version: 0.15.9(typescript@5.9.3)
packages/data:
dependencies:
@@ -1307,7 +1317,7 @@ importers:
version: 0.15.9(typescript@5.9.3)
unplugin-dts:
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rolldown@1.0.0-beta.44)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
vite:
specifier: 7.1.12
version: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
@@ -6101,6 +6111,14 @@ packages:
bytewise@1.1.0:
resolution: {integrity: sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==}
c12@1.11.2:
resolution: {integrity: sha512-oBs8a4uvSDO9dm8b7OCFW7+dgtVrwmwnrVXYzLm43ta7ep2jCn/0MhoUFygIWtxhyy6+/MG7/agvpY0U1Iemew==}
peerDependencies:
magicast: ^0.3.4
peerDependenciesMeta:
magicast:
optional: true
c12@3.3.0:
resolution: {integrity: sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==}
peerDependencies:
@@ -6188,6 +6206,10 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
chroma-js@3.1.2:
resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==}
@@ -6650,15 +6672,6 @@ packages:
supports-color:
optional: true
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -7491,6 +7504,10 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -7577,6 +7594,10 @@ packages:
resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==}
engines: {node: '>=0.10.0'}
giget@1.2.5:
resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==}
hasBin: true
giget@2.0.0:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
@@ -8103,6 +8124,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -8717,16 +8742,30 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
minipass@5.0.0:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
mixin-deep@1.3.2:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
mlly@1.7.4:
resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -8792,6 +8831,10 @@ packages:
resolution: {integrity: sha512-jXqoeazfaG3VCFsYFZ3NzHfDe2ZR+XWxj0XV5SAWNR339GxE5mNsZtfxe3k2l04QxcGP5IidSPO5d2AsXnOvbQ==}
hasBin: true
nbump@2.1.8:
resolution: {integrity: sha512-xbqzRVnGPNCVHBi4+0qrm1jSpZPZ1uZaJrYt/JAvnhf2WDeELGvDxdcqL9fn9ExlwU4g7K9GjdG7gVI794bceg==}
hasBin: true
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -8868,6 +8911,11 @@ packages:
numeral@2.0.6:
resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==}
nypm@0.5.4:
resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
nypm@0.6.2:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
@@ -8895,6 +8943,9 @@ packages:
ofetch@1.4.1:
resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==}
ohash@1.1.6:
resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@@ -8947,6 +8998,9 @@ packages:
package-manager-detector@1.3.0:
resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==}
package-manager-detector@1.5.0:
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
@@ -9018,6 +9072,9 @@ packages:
pathe@0.2.0:
resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==}
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -9029,6 +9086,9 @@ packages:
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
hasBin: true
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.0.0:
resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==}
@@ -10592,6 +10652,10 @@ packages:
resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
engines: {node: '>=6'}
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@@ -12115,7 +12179,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.4
'@babel/types': 7.28.5
'@babel/helper-compilation-targets@7.27.2':
dependencies:
@@ -12185,7 +12249,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -12199,7 +12263,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.0
'@babel/types': 7.28.2
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -12223,7 +12287,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.28.4
'@babel/types': 7.28.5
'@babel/helper-plugin-utils@7.27.1': {}
@@ -12257,7 +12321,7 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -12284,7 +12348,7 @@ snapshots:
'@babel/parser@7.28.0':
dependencies:
'@babel/types': 7.28.2
'@babel/types': 7.28.5
'@babel/parser@7.28.4':
dependencies:
@@ -12810,11 +12874,11 @@ snapshots:
'@babel/traverse@7.28.0':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.3
'@babel/generator': 7.28.5
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.4
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/types': 7.28.2
'@babel/types': 7.28.5
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -12971,7 +13035,7 @@ snapshots:
'@conventional-changelog/git-client@1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.0)':
dependencies:
'@types/semver': 7.7.1
semver: 7.7.2
semver: 7.7.3
optionalDependencies:
conventional-commits-filter: 5.0.0
conventional-commits-parser: 6.2.0
@@ -16246,7 +16310,7 @@ snapshots:
'@tokenizer/inflate@0.2.7':
dependencies:
debug: 4.4.1
debug: 4.4.3(supports-color@5.5.0)
fflate: 0.8.2
token-types: 6.0.0
transitivePeerDependencies:
@@ -16940,7 +17004,7 @@ snapshots:
'@vue/compiler-core@3.5.21':
dependencies:
'@babel/parser': 7.28.4
'@babel/parser': 7.28.5
'@vue/shared': 3.5.21
entities: 4.5.0
estree-walker: 2.0.2
@@ -17367,6 +17431,23 @@ snapshots:
bytewise-core: 1.2.3
typewise: 1.0.3
c12@1.11.2(magicast@0.3.5):
dependencies:
chokidar: 3.6.0
confbox: 0.1.8
defu: 6.1.4
dotenv: 16.6.1
giget: 1.2.5
jiti: 1.21.7
mlly: 1.8.0
ohash: 1.1.6
pathe: 1.1.2
perfect-debounce: 1.0.0
pkg-types: 1.3.1
rc9: 2.1.2
optionalDependencies:
magicast: 0.3.5
c12@3.3.0(magicast@0.3.5):
dependencies:
chokidar: 4.0.3
@@ -17471,6 +17552,8 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@2.0.0: {}
chroma-js@3.1.2: {}
ci-info@4.3.0: {}
@@ -17638,7 +17721,7 @@ snapshots:
conventional-commits-filter: 5.0.0
handlebars: 4.7.8
meow: 13.2.0
semver: 7.7.2
semver: 7.7.3
conventional-changelog@6.0.0(conventional-commits-filter@5.0.0):
dependencies:
@@ -17960,10 +18043,6 @@ snapshots:
ms: 2.1.3
optional: true
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
@@ -19030,6 +19109,10 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
fs.realpath@1.0.0: {}
fsevents@2.3.2:
@@ -19112,6 +19195,16 @@ snapshots:
get-value@2.0.6: {}
giget@1.2.5:
dependencies:
citty: 0.1.6
consola: 3.4.2
defu: 6.1.4
node-fetch-native: 1.6.7
nypm: 0.5.4
pathe: 2.0.3
tar: 6.2.1
giget@2.0.0:
dependencies:
citty: 0.1.6
@@ -19728,6 +19821,8 @@ snapshots:
filelist: 1.0.4
minimatch: 3.1.2
jiti@1.21.7: {}
jiti@2.6.1: {}
jju@1.4.0:
@@ -20685,19 +20780,25 @@ snapshots:
minimist@1.2.8: {}
minipass@3.3.6:
dependencies:
yallist: 4.0.0
minipass@5.0.0: {}
minipass@7.1.2: {}
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
mixin-deep@1.3.2:
dependencies:
for-in: 1.0.2
is-extendable: 1.0.1
mlly@1.7.4:
dependencies:
acorn: 8.15.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.1
mkdirp@1.0.4: {}
mlly@1.8.0:
dependencies:
@@ -20758,6 +20859,16 @@ snapshots:
- conventional-commits-filter
- magicast
nbump@2.1.8(conventional-commits-filter@5.0.0)(magicast@0.3.5):
dependencies:
c12: 3.3.0(magicast@0.3.5)
conventional-changelog: 6.0.0(conventional-commits-filter@5.0.0)
package-manager-detector: 1.5.0
zx: 8.8.2
transitivePeerDependencies:
- conventional-commits-filter
- magicast
neo-async@2.6.2: {}
next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
@@ -20824,7 +20935,7 @@ snapshots:
normalize-package-data@6.0.2:
dependencies:
hosted-git-info: 7.0.2
semver: 7.7.2
semver: 7.7.3
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
@@ -20842,6 +20953,15 @@ snapshots:
numeral@2.0.6: {}
nypm@0.5.4:
dependencies:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 1.3.1
tinyexec: 0.3.2
ufo: 1.6.1
nypm@0.6.2:
dependencies:
citty: 0.1.6
@@ -20874,6 +20994,8 @@ snapshots:
node-fetch-native: 1.6.7
ufo: 1.6.1
ohash@1.1.6: {}
ohash@2.0.11: {}
on-change@4.0.2: {}
@@ -20932,6 +21054,8 @@ snapshots:
package-manager-detector@1.3.0: {}
package-manager-detector@1.5.0: {}
pako@2.1.0: {}
param-case@3.0.4:
@@ -21004,6 +21128,8 @@ snapshots:
pathe@0.2.0: {}
pathe@1.1.2: {}
pathe@2.0.3: {}
pbf@3.3.0:
@@ -21016,6 +21142,8 @@ snapshots:
dependencies:
resolve-protobuf-schema: 2.1.0
perfect-debounce@1.0.0: {}
perfect-debounce@2.0.0: {}
pg-cloudflare@1.2.7:
@@ -21080,7 +21208,7 @@ snapshots:
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.7.4
mlly: 1.8.0
pathe: 2.0.3
pkg-types@2.3.0:
@@ -22262,15 +22390,15 @@ snapshots:
rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.44)(typescript@5.9.3):
dependencies:
'@babel/generator': 7.28.3
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@babel/generator': 7.28.5
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
ast-kit: 2.1.3
birpc: 2.6.1
debug: 4.4.3(supports-color@5.5.0)
dts-resolver: 2.1.2
get-tsconfig: 4.13.0
magic-string: 0.30.19
magic-string: 0.30.21
rolldown: 1.0.0-beta.44
optionalDependencies:
typescript: 5.9.3
@@ -22864,6 +22992,15 @@ snapshots:
tapable@2.2.3: {}
tar@6.2.1:
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
temp-dir@2.0.0: {}
tempy@0.6.0:
@@ -23201,7 +23338,7 @@ snapshots:
magic-string-ast: 1.0.3
unplugin: 2.3.10
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rolldown@1.0.0-beta.44)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.1))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
'@volar/typescript': 2.4.23
@@ -23215,7 +23352,6 @@ snapshots:
optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.9.1)
esbuild: 0.25.11
rolldown: 1.0.0-beta.44
rollup: 4.52.5
vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
@@ -23740,8 +23876,7 @@ snapshots:
yallist@3.1.1: {}
yallist@4.0.0:
optional: true
yallist@4.0.0: {}
yaml@1.10.2: {}

View File

@@ -1,2 +1,2 @@
[ -f config.json ] || cp config.example.json config.json
[ -f builder.config.json ] || cp builder.config.example.json builder.config.json
[ -f builder.config.ts ] || cp builder.config.default.ts builder.config.ts

View File

@@ -2,10 +2,12 @@
cp -r photos ./apps/web/public/photos
echo '{
"storage": {
"provider": "local",
"basePath": "./apps/web/public/photos",
"baseUrl": "/photos"
}
}' >builder.config.json
echo 'import { defineBuilderConfig } from "@afilmory/builder";
export default defineBuilderConfig(() => ({
storage: {
provider: "local",
basePath: "./apps/web/public/photos",
baseUrl: "/photos",
},
}))' >builder.config.ts