From af170a43bb3586119b8fa94a7589e6601762f7e3 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 28 Oct 2025 19:48:04 +0800 Subject: [PATCH] refactor!: builder pipe and plugin system Signed-off-by: Innei --- .github/copilot-instructions.md | 132 ---- .gitignore | 2 +- AGENTS.md | 9 +- README.md | 2 +- apps/ssr/next-env.d.ts | 2 +- apps/web/scripts/precheck.ts | 2 +- apps/web/tsconfig.json | 5 +- .../components/ProviderCard.tsx | 3 - ...der.config.ts => builder.config.default.ts | 44 +- builder.config.example.json | 10 - package.json | 1 + packages/builder/bump.config.ts | 12 + packages/builder/package.json | 27 +- packages/builder/src/builder/builder.ts | 614 ++++++++++++------ packages/builder/src/cli.ts | 222 +------ packages/builder/src/config/defaults.ts | 58 ++ packages/builder/src/config/index.ts | 65 ++ packages/builder/src/index.ts | 27 +- packages/builder/src/photo/image-pipeline.ts | 58 +- packages/builder/src/photo/processor.ts | 10 +- .../builder/src/plugins/github-repo-sync.ts | 244 +++++++ packages/builder/src/plugins/loader.ts | 165 +++++ packages/builder/src/plugins/manager.ts | 122 ++++ packages/builder/src/plugins/storage/eagle.ts | 24 + .../builder/src/plugins/storage/github.ts | 24 + packages/builder/src/plugins/storage/local.ts | 24 + packages/builder/src/plugins/storage/s3.ts | 24 + packages/builder/src/plugins/types.ts | 192 ++++++ packages/builder/src/runAsWorker.ts | 37 +- packages/builder/src/storage/factory.ts | 47 +- packages/builder/src/storage/index.ts | 1 + packages/builder/src/types/config.ts | 10 + packages/builder/src/types/photo.ts | 1 + packages/builder/src/utils/clone.ts | 15 + packages/builder/tsconfig.json | 3 - packages/builder/tsdown.config.ts | 9 + packages/docs/contents/deployment/docker.mdx | 56 +- packages/docs/contents/index.mdx | 12 +- packages/docs/contents/storage/index.mdx | 138 ++-- pnpm-lock.yaml | 231 +++++-- scripts/preinstall.sh | 2 +- scripts/prepare-demo-data.sh | 16 +- 42 files changed, 1896 insertions(+), 806 deletions(-) delete mode 100644 .github/copilot-instructions.md rename builder.config.ts => builder.config.default.ts (51%) delete mode 100644 builder.config.example.json create mode 100644 packages/builder/bump.config.ts create mode 100644 packages/builder/src/config/defaults.ts create mode 100644 packages/builder/src/config/index.ts create mode 100644 packages/builder/src/plugins/github-repo-sync.ts create mode 100644 packages/builder/src/plugins/loader.ts create mode 100644 packages/builder/src/plugins/manager.ts create mode 100644 packages/builder/src/plugins/storage/eagle.ts create mode 100644 packages/builder/src/plugins/storage/github.ts create mode 100644 packages/builder/src/plugins/storage/local.ts create mode 100644 packages/builder/src/plugins/storage/s3.ts create mode 100644 packages/builder/src/plugins/types.ts create mode 100644 packages/builder/src/utils/clone.ts create mode 100644 packages/builder/tsdown.config.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 152e5cc8..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -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 ` 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 { - /* */ - } - async getFile(key: string): Promise { - /* */ - } - // 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 diff --git a/.gitignore b/.gitignore index 85030e0f..234dffc1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 28483b1d..b4cfc103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 66bfc6aa..ab12bdaa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/ssr/next-env.d.ts b/apps/ssr/next-env.d.ts index c4e7c0eb..a3e4680c 100644 --- a/apps/ssr/next-env.d.ts +++ b/apps/ssr/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/apps/web/scripts/precheck.ts b/apps/web/scripts/precheck.ts index 4ae6b81f..66417637 100644 --- a/apps/web/scripts/precheck.ts +++ b/apps/web/scripts/precheck.ts @@ -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, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 99346168..01f88963 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -30,9 +30,6 @@ "@config": [ "../../site.config.ts" ], - "@builder": [ - "../../builder.config.ts" - ], "@env": [ "../../env.ts" ], @@ -45,4 +42,4 @@ "./src/**/*", "./scripts/**/*" ] -} \ No newline at end of file +} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx index e617e9c2..e147a261 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx @@ -66,9 +66,6 @@ export const ProviderCard: FC = ({ case 'local': { return cfg.path || 'Not configured' } - case 'minio': { - return cfg.endpoint || 'Not configured' - } case 'eagle': { return cfg.libraryPath || 'Not configured' } diff --git a/builder.config.ts b/builder.config.default.ts similarity index 51% rename from builder.config.ts rename to builder.config.default.ts index 7a6155a3..55ce03a5 100644 --- a/builder.config.ts +++ b/builder.config.default.ts @@ -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: [], +})) diff --git a/builder.config.example.json b/builder.config.example.json deleted file mode 100644 index fe399a2a..00000000 --- a/builder.config.example.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/package.json b/package.json index 73c5938b..ff31351d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/builder/bump.config.ts b/packages/builder/bump.config.ts new file mode 100644 index 00000000..07c1808d --- /dev/null +++ b/packages/builder/bump.config.ts @@ -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, +}) diff --git a/packages/builder/package.json b/packages/builder/package.json index aac12280..f49cfb6c 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -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" + } + } } -} \ No newline at end of file +} diff --git a/packages/builder/src/builder/builder.ts b/packages/builder/src/builder/builder.ts index bead509b..e817e202 100644 --- a/packages/builder/src/builder/builder.ts +++ b/packages/builder/src/builder/builder.ts @@ -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 { 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 { 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({ - 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({ - 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({ + 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({ + 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( + runState: PluginRunState, + event: TEvent, + payload: BuilderPluginEventPayloads[TEvent], + ): Promise { + await this.pluginManager.emit(this, runState, event, payload) + } + + async ensurePluginsReady(): Promise { + await this.pluginManager.ensureLoaded(this) + } + + private resolvePluginReferences(): BuilderPluginConfigEntry[] { + const references: BuilderPluginConfigEntry[] = [] + const seen = new Set() + + 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 = { + 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(value: T): T { - const maybeStructuredClone = ( - globalThis as typeof globalThis & { - structuredClone?: (input: U) => U - } - ).structuredClone - - if (typeof maybeStructuredClone === 'function') { - return maybeStructuredClone(value) - } - - return v8Deserialize(v8Serialize(value)) -} diff --git a/packages/builder/src/cli.ts b/packages/builder/src/cli.ts index bd409e72..d871917a 100644 --- a/packages/builder/src/cli.ts +++ b/packages/builder/src/cli.ts @@ -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 { - 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) } diff --git a/packages/builder/src/config/defaults.ts b/packages/builder/src/config/defaults.ts new file mode 100644 index 00000000..ce104eeb --- /dev/null +++ b/packages/builder/src/config/defaults.ts @@ -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: [], + } +} diff --git a/packages/builder/src/config/index.ts b/packages/builder/src/config/index.ts new file mode 100644 index 00000000..57fcc31f --- /dev/null +++ b/packages/builder/src/config/index.ts @@ -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) 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 { + const defaults = options.defaults ?? createDefaultBuilderConfig() + + const result = await loadConfig({ + 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 +} diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts index 87d89fdd..f23063ab 100644 --- a/packages/builder/src/index.ts +++ b/packages/builder/src/index.ts @@ -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, diff --git a/packages/builder/src/photo/image-pipeline.ts b/packages/builder/src/photo/image-pipeline.ts index 77028330..c2fd2585 100644 --- a/packages/builder/src/photo/image-pipeline.ts +++ b/packages/builder/src/photo/image-pipeline.ts @@ -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 options: PhotoProcessorOptions + pluginData: Record } /** @@ -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 }> { 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 } diff --git a/packages/builder/src/photo/processor.ts b/packages/builder/src/photo/processor.ts index 55a398b8..29839d95 100644 --- a/packages/builder/src/photo/processor.ts +++ b/packages/builder/src/photo/processor.ts @@ -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, options: PhotoProcessorOptions, builder: AfilmoryBuilder, + pluginRuntime: { + runState: PluginRunState + builderOptions: BuilderOptions + }, ): Promise { 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) } diff --git a/packages/builder/src/plugins/github-repo-sync.ts b/packages/builder/src/plugins/github-repo-sync.ts new file mode 100644 index 00000000..71b9af02 --- /dev/null +++ b/packages/builder/src/plugins/github-repo-sync.ts @@ -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 { + 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 { + 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 { + 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) +} diff --git a/packages/builder/src/plugins/loader.ts b/packages/builder/src/plugins/loader.ts new file mode 100644 index 00000000..c5ede046 --- /dev/null +++ b/packages/builder/src/plugins/loader.ts @@ -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 { + 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 { + 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)[key] = value + } + } + + return hooks +} + +export async function loadPlugins( + entries: BuilderPluginConfigEntry[] = [], + options: { baseDir: string }, +): Promise { + 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 +} diff --git a/packages/builder/src/plugins/manager.ts b/packages/builder/src/plugins/manager.ts new file mode 100644 index 00000000..a8817bb3 --- /dev/null +++ b/packages/builder/src/plugins/manager.ts @@ -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> + +export class PluginManager { + private readonly entries: BuilderPluginConfigEntry[] + private readonly baseDir: string + private plugins: Awaited> = [] + private loadPromise: Promise | 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 { + 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( + builder: AfilmoryBuilder, + runState: PluginRunState, + event: TEvent, + payload: BuilderPluginEventPayloads[TEvent], + ): Promise { + if (this.plugins.length === 0) return + + for (const plugin of this.plugins) { + const hook = plugin.hooks[event] as + | ((context: BuilderPluginHookContext) => void | Promise) + | 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 = { + 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 + } + } + } +} diff --git a/packages/builder/src/plugins/storage/eagle.ts b/packages/builder/src/plugins/storage/eagle.ts new file mode 100644 index 00000000..96054e1a --- /dev/null +++ b/packages/builder/src/plugins/storage/eagle.ts @@ -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) + }) + }, + }, + } +} diff --git a/packages/builder/src/plugins/storage/github.ts b/packages/builder/src/plugins/storage/github.ts new file mode 100644 index 00000000..627e13e2 --- /dev/null +++ b/packages/builder/src/plugins/storage/github.ts @@ -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) + }) + }, + }, + } +} diff --git a/packages/builder/src/plugins/storage/local.ts b/packages/builder/src/plugins/storage/local.ts new file mode 100644 index 00000000..572d2794 --- /dev/null +++ b/packages/builder/src/plugins/storage/local.ts @@ -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) + }) + }, + }, + } +} diff --git a/packages/builder/src/plugins/storage/s3.ts b/packages/builder/src/plugins/storage/s3.ts new file mode 100644 index 00000000..a1e9994a --- /dev/null +++ b/packages/builder/src/plugins/storage/s3.ts @@ -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) + }) + }, + }, + } +} diff --git a/packages/builder/src/plugins/types.ts b/packages/builder/src/plugins/types.ts new file mode 100644 index 00000000..cd83f2fb --- /dev/null +++ b/packages/builder/src/plugins/types.ts @@ -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 + } + } + photoProcessError: { + options: BuilderOptions + context: PhotoProcessingContext + error: unknown + } + afterManifestLoad: { + options: BuilderOptions + manifest: AfilmoryManifest + manifestMap: Map + } + afterAllFilesListed: { + options: BuilderOptions + allObjects: StorageObject[] + } + afterLivePhotoDetection: { + options: BuilderOptions + livePhotoMap: Map + } + 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 + 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 { + 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 + event: TEvent + payload: BuilderPluginEventPayloads[TEvent] +} + +export type BuilderPluginHook = ( + context: BuilderPluginHookContext, +) => void | Promise + +export type BuilderPluginLifecycleHooks = Partial<{ + [Event in BuilderPluginEvent]: BuilderPluginHook +}> + +export interface BuilderPluginHooks extends BuilderPluginLifecycleHooks { + onInit?: (context: BuilderPluginInitContext) => void | Promise +} + +export interface BuilderPlugin { + name?: string + hooks?: BuilderPluginHooks +} + +export type BuilderPluginFactory = + | BuilderPlugin + | (() => BuilderPlugin | Promise) + | ((options: unknown) => BuilderPlugin | Promise) + +export function isPluginReferenceObject( + value: BuilderPluginConfigEntry, +): value is Exclude { + return typeof value === 'object' && value !== null && 'resolve' in value +} diff --git a/packages/builder/src/runAsWorker.ts b/packages/builder/src/runAsWorker.ts index 1cdba180..3c83e613 100644 --- a/packages/builder/src/runAsWorker.ts +++ b/packages/builder/src/runAsWorker.ts @@ -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 let livePhotoMap: Map 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[] = [] + 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, + }, ) // 添加成功结果 diff --git a/packages/builder/src/storage/factory.ts b/packages/builder/src/storage/factory.ts index 1406fa1b..8d5c0b18 100644 --- a/packages/builder/src/storage/factory.ts +++ b/packages/builder/src/storage/factory.ts @@ -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 = ( + config: T, +) => StorageProvider export class StorageFactory { + private static providers = new Map() + + /** + * 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()) } } diff --git a/packages/builder/src/storage/index.ts b/packages/builder/src/storage/index.ts index c08747ae..9ec0cd60 100644 --- a/packages/builder/src/storage/index.ts +++ b/packages/builder/src/storage/index.ts @@ -8,6 +8,7 @@ export type { } from './interfaces.js' // 导出工厂类 +export type { StorageProviderFactory } from './factory.js' export { StorageFactory } from './factory.js' // 导出管理器 diff --git a/packages/builder/src/types/config.ts b/packages/builder/src/types/config.ts index 258c74e4..33d2fc4e 100644 --- a/packages/builder/src/types/config.ts +++ b/packages/builder/src/types/config.ts @@ -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 extends object + ? { [K in keyof T]?: DeepPartial } + : T + +export type BuilderConfigInput = DeepPartial> & { + plugins?: BuilderPluginConfigEntry[] } diff --git a/packages/builder/src/types/photo.ts b/packages/builder/src/types/photo.ts index 1b69833b..bdb02dbf 100644 --- a/packages/builder/src/types/photo.ts +++ b/packages/builder/src/types/photo.ts @@ -63,6 +63,7 @@ export interface PhotoManifestItem extends PhotoInfo { export interface ProcessPhotoResult { item: PhotoManifestItem | null type: 'processed' | 'skipped' | 'new' | 'failed' + pluginData?: Record } export interface PickedExif { diff --git a/packages/builder/src/utils/clone.ts b/packages/builder/src/utils/clone.ts new file mode 100644 index 00000000..c71aa1f9 --- /dev/null +++ b/packages/builder/src/utils/clone.ts @@ -0,0 +1,15 @@ +import { deserialize as v8Deserialize, serialize as v8Serialize } from 'node:v8' + +export function clone(value: T): T { + const maybeStructuredClone = ( + globalThis as typeof globalThis & { + structuredClone?: (input: U) => U + } + ).structuredClone + + if (typeof maybeStructuredClone === 'function') { + return maybeStructuredClone(value) + } + + return v8Deserialize(v8Serialize(value)) +} diff --git a/packages/builder/tsconfig.json b/packages/builder/tsconfig.json index fa6725aa..ac11bd4b 100644 --- a/packages/builder/tsconfig.json +++ b/packages/builder/tsconfig.json @@ -27,9 +27,6 @@ "@pkg": [ "./package.json" ], - "@builder": [ - "../../builder.config.ts" - ], "@env": [ "../../env.ts" ] diff --git a/packages/builder/tsdown.config.ts b/packages/builder/tsdown.config.ts new file mode 100644 index 00000000..3388391c --- /dev/null +++ b/packages/builder/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: './src/index.ts', + outDir: './dist', + format: 'esm', + clean: true, + dts: true, +}) diff --git a/packages/docs/contents/deployment/docker.mdx b/packages/docs/contents/deployment/docker.mdx index 7b73ac06..bd2d9bed 100644 --- a/packages/docs/contents/deployment/docker.mdx +++ b/packages/docs/contents/deployment/docker.mdx @@ -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: } } ``` - diff --git a/packages/docs/contents/index.mdx b/packages/docs/contents/index.mdx index 24b8ecd8..73371883 100644 --- a/packages/docs/contents/index.mdx +++ b/packages/docs/contents/index.mdx @@ -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

- Afilmory + Afilmory

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! \ No newline at end of file +If this project helps you, please give it a ⭐️ Star for support! diff --git a/packages/docs/contents/storage/index.mdx b/packages/docs/contents/storage/index.mdx index 733f7f12..4767e581 100644 --- a/packages/docs/contents/storage/index.mdx +++ b/packages/docs/contents/storage/index.mdx @@ -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 frontend’s static assets. On initialization/build, files from `basePath` are copied to `distPath` (overwriting same-named files). +Use when you want originals deployed alongside your frontend’s 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 -``` \ No newline at end of file +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6052700..0e2ebb09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/preinstall.sh b/scripts/preinstall.sh index 0cb4cb8d..e411205f 100644 --- a/scripts/preinstall.sh +++ b/scripts/preinstall.sh @@ -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 diff --git a/scripts/prepare-demo-data.sh b/scripts/prepare-demo-data.sh index 8ffc11cb..f7c6b4b7 100644 --- a/scripts/prepare-demo-data.sh +++ b/scripts/prepare-demo-data.sh @@ -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