mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-30 01:36:49 +00:00
refactor!: builder pipe and plugin system
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
132
.github/copilot-instructions.md
vendored
132
.github/copilot-instructions.md
vendored
@@ -1,132 +0,0 @@
|
||||
# Afilmory Codebase Guide for AI Agents
|
||||
|
||||
## Project Architecture
|
||||
|
||||
Afilmory is a modern photo gallery monorepo with sophisticated image processing capabilities:
|
||||
|
||||
- **`apps/ssr/`** - Next.js 15 SSR application with database and APIs
|
||||
- **`apps/web/`** - React 19 frontend with Vite, WebGL viewer, masonry layout
|
||||
- **`packages/builder/`** - Photo processing engine with S3/GitHub storage adapters
|
||||
- **`packages/docs/`** - Vite SSG documentation site (planned custom implementation)
|
||||
- **`packages/webgl-viewer/`** - High-performance WebGL photo viewer component
|
||||
|
||||
## Critical Development Commands
|
||||
|
||||
```bash
|
||||
# Photo processing & manifest generation
|
||||
pnpm run build:manifest # Incremental photo sync
|
||||
pnpm run build:manifest -- --force # Full rebuild
|
||||
pnpm run build:manifest -- --force-thumbnails # Regenerate thumbnails only
|
||||
|
||||
# Development servers
|
||||
pnpm dev # Starts both SSR + web apps
|
||||
pnpm --filter @afilmory/ssr dev # SSR only (Next.js on :1924)
|
||||
pnpm --filter web dev # Web only (Vite)
|
||||
|
||||
# Database operations (SSR app)
|
||||
pnpm --filter @afilmory/ssr db:generate # Generate Drizzle migrations
|
||||
pnpm --filter @afilmory/ssr db:migrate # Run migrations
|
||||
```
|
||||
|
||||
## Code Quality & Architecture Patterns
|
||||
|
||||
### State Management Architecture
|
||||
|
||||
- **Jotai** for client state in web app - use atomic patterns, avoid large atoms
|
||||
- **TanStack Query** for server state - leverage cache invalidation patterns
|
||||
- **React Context** - follow composition over deep nesting, prevent re-renders
|
||||
|
||||
### Photo Processing Pipeline
|
||||
|
||||
1. **Storage Adapters** (`packages/builder/src/storage/`) - implement `StorageProvider` interface
|
||||
2. **Format Conversion** - HEIC/TIFF → web formats via Sharp
|
||||
3. **EXIF Extraction** - camera settings, GPS, Fujifilm recipes
|
||||
4. **Concurrent Processing** - worker threads/cluster mode in `builderConfig.performance`
|
||||
|
||||
### WebGL Integration
|
||||
|
||||
- Custom WebGL viewer in `packages/webgl-viewer/` for high-performance rendering
|
||||
- Gesture support, zoom/pan operations
|
||||
- Integration with masonry layout via Masonic
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **`builder.config.json`** - photo processing, storage, performance settings
|
||||
- **`config.json`** - site metadata merged with `site.config.ts` defaults
|
||||
- **`env.ts`** - centralized environment validation with Zod schemas
|
||||
|
||||
### Apple UIKit Color System
|
||||
|
||||
```typescript
|
||||
// Use semantic Tailwind classes from tailwindcss-uikit-colors
|
||||
className = 'text-text-primary bg-fill-secondary material-thin'
|
||||
// NOT generic colors like "text-blue-500"
|
||||
```
|
||||
|
||||
### i18n Flat Key Structure
|
||||
|
||||
```json
|
||||
// locales/app/en.json - use dot notation, avoid nesting
|
||||
{
|
||||
"exif.camera.model": "Camera Model",
|
||||
"photo.count_one": "{{count}} photo",
|
||||
"photo.count_other": "{{count}} photos"
|
||||
}
|
||||
```
|
||||
|
||||
### Monorepo Workspace Patterns
|
||||
|
||||
- Use `pnpm --filter <package>` for targeted operations
|
||||
- Shared dependencies in `pnpm-workspace.yaml` catalog
|
||||
- Cross-package imports via workspace protocol: `"@afilmory/components": "workspace:*"`
|
||||
|
||||
## Storage Provider Integration
|
||||
|
||||
When extending storage support, implement the adapter pattern:
|
||||
|
||||
```typescript
|
||||
// packages/builder/src/storage/providers/
|
||||
class NewStorageProvider implements StorageProvider {
|
||||
async listImages(): Promise<StorageObject[]> {
|
||||
/* */
|
||||
}
|
||||
async getFile(key: string): Promise<Buffer | null> {
|
||||
/* */
|
||||
}
|
||||
// Key methods for storage abstraction
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Photo Processing**: Configure worker pools in `builderConfig.performance.worker`
|
||||
- **WebGL Viewer**: Implement texture memory management and disposal
|
||||
- **Bundle Splitting**: Leverage Vite's code splitting for image processing tools
|
||||
- **Image Optimization**: Use Sharp for thumbnails, Blurhash for placeholders
|
||||
|
||||
## Documentation Site (packages/docs/)
|
||||
|
||||
Currently planned as a custom Vite SSG implementation:
|
||||
|
||||
- **MDX** with React components, math (KaTeX), charts (Mermaid)
|
||||
- **Custom Vite plugins** for file-system routing and search indexing
|
||||
- **Design system** aligned with main app's Apple UIKit colors
|
||||
- Reference `packages/docs/requirements.md` and `tasks.md` for implementation details
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Database**: Drizzle ORM with PostgreSQL for SSR app
|
||||
- **Image CDN**: S3-compatible storage with custom domain support
|
||||
- **Map Integration**: MapLibre for GPS photo locations
|
||||
- **Live Photos**: iOS video detection and playback support
|
||||
- **RSS/Social**: OpenGraph metadata and feed generation
|
||||
|
||||
## Other Considerations
|
||||
|
||||
- Don't have to implement everything at once; focus on core features first
|
||||
- Use existing packages as references for implementation patterns
|
||||
- Follow the established architecture for consistency
|
||||
- Keep documentation up-to-date with code changes
|
||||
- If you need to run commands, ask for help, don't run them blindly
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
2
apps/ssr/next-env.d.ts
vendored
2
apps/ssr/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts'
|
||||
import './.next/dev/types/routes.d.ts'
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"@config": [
|
||||
"../../site.config.ts"
|
||||
],
|
||||
"@builder": [
|
||||
"../../builder.config.ts"
|
||||
],
|
||||
"@env": [
|
||||
"../../env.ts"
|
||||
],
|
||||
@@ -45,4 +42,4 @@
|
||||
"./src/**/*",
|
||||
"./scripts/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,6 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
case 'local': {
|
||||
return cfg.path || 'Not configured'
|
||||
}
|
||||
case 'minio': {
|
||||
return cfg.endpoint || 'Not configured'
|
||||
}
|
||||
case 'eagle': {
|
||||
return cfg.libraryPath || 'Not configured'
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}))
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
12
packages/builder/bump.config.ts
Normal file
12
packages/builder/bump.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'nbump'
|
||||
|
||||
export default defineConfig({
|
||||
leading: ['npm run build'],
|
||||
publish: true,
|
||||
allowDirty: true,
|
||||
allowedBranches: ['dev/*', 'main'],
|
||||
withTags: false,
|
||||
tag: false,
|
||||
commit: false,
|
||||
push: false,
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import { deserialize as v8Deserialize, serialize as v8Serialize } from 'node:v8'
|
||||
|
||||
import { thumbnailExists } from '../image/thumbnail.js'
|
||||
import { logger } from '../logger/index.js'
|
||||
@@ -12,7 +11,15 @@ import {
|
||||
import { CURRENT_MANIFEST_VERSION } from '../manifest/version.js'
|
||||
import type { PhotoProcessorOptions } from '../photo/processor.js'
|
||||
import { processPhoto } from '../photo/processor.js'
|
||||
import { StorageManager } from '../storage/index.js'
|
||||
import type { PluginRunState } from '../plugins/manager.js'
|
||||
import { PluginManager } from '../plugins/manager.js'
|
||||
import type {
|
||||
BuilderPluginConfigEntry,
|
||||
BuilderPluginEventPayloads,
|
||||
} from '../plugins/types.js'
|
||||
import { isPluginReferenceObject } from '../plugins/types.js'
|
||||
import type { StorageProviderFactory } from '../storage/factory.js'
|
||||
import { StorageFactory, StorageManager } from '../storage/index.js'
|
||||
import type { BuilderConfig } from '../types/config.js'
|
||||
import type {
|
||||
AfilmoryManifest,
|
||||
@@ -20,6 +27,7 @@ import type {
|
||||
LensInfo,
|
||||
} from '../types/manifest.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import { clone } from '../utils/clone.js'
|
||||
import { ClusterPool } from '../worker/cluster-pool.js'
|
||||
import { WorkerPool } from '../worker/pool.js'
|
||||
|
||||
@@ -40,15 +48,20 @@ export interface BuilderResult {
|
||||
}
|
||||
|
||||
export class AfilmoryBuilder {
|
||||
private storageManager: StorageManager
|
||||
private storageManager: StorageManager | null = null
|
||||
private config: BuilderConfig
|
||||
private pluginManager: PluginManager
|
||||
private readonly pluginReferences: BuilderPluginConfigEntry[]
|
||||
|
||||
constructor(config: BuilderConfig) {
|
||||
// 创建配置副本,避免外部修改
|
||||
this.config = cloneConfig(config)
|
||||
this.config = clone(config)
|
||||
|
||||
// 创建存储管理器
|
||||
this.storageManager = new StorageManager(this.config.storage)
|
||||
this.pluginReferences = this.resolvePluginReferences()
|
||||
|
||||
this.pluginManager = new PluginManager(this.pluginReferences, {
|
||||
baseDir: process.cwd(),
|
||||
})
|
||||
|
||||
// 配置日志级别(保留接口以便未来扩展)
|
||||
this.configureLogging()
|
||||
@@ -60,6 +73,8 @@ export class AfilmoryBuilder {
|
||||
|
||||
async buildManifest(options: BuilderOptions): Promise<BuilderResult> {
|
||||
try {
|
||||
await this.ensurePluginsReady()
|
||||
this.ensureStorageManager()
|
||||
return await this.#buildManifest(options)
|
||||
} catch (error) {
|
||||
logger.main.error('❌ 构建 manifest 失败:', error)
|
||||
@@ -72,99 +87,120 @@ export class AfilmoryBuilder {
|
||||
*/
|
||||
async #buildManifest(options: BuilderOptions): Promise<BuilderResult> {
|
||||
const startTime = Date.now()
|
||||
|
||||
this.logBuildStart()
|
||||
|
||||
// 读取现有的 manifest(如果存在)
|
||||
const existingManifestItems = await this.loadExistingManifest(options).then(
|
||||
(manifest) => manifest.data,
|
||||
)
|
||||
const existingManifestMap = new Map(
|
||||
existingManifestItems.map((item) => [item.s3Key, item]),
|
||||
)
|
||||
|
||||
logger.main.info(
|
||||
`现有 manifest 包含 ${existingManifestItems.length} 张照片`,
|
||||
)
|
||||
|
||||
logger.main.info('使用存储提供商:', this.config.storage.provider)
|
||||
// 列出存储中的所有文件
|
||||
const allObjects = await this.storageManager.listAllFiles()
|
||||
logger.main.info(`存储中找到 ${allObjects.length} 个文件`)
|
||||
|
||||
// 检测 Live Photo 配对(如果启用)
|
||||
const livePhotoMap = await this.detectLivePhotos(allObjects)
|
||||
if (this.config.options.enableLivePhotoDetection) {
|
||||
logger.main.info(`检测到 ${livePhotoMap.size} 个 Live Photo`)
|
||||
}
|
||||
|
||||
// 列出存储中的所有图片文件
|
||||
const imageObjects = await this.storageManager.listImages()
|
||||
logger.main.info(`存储中找到 ${imageObjects.length} 张照片`)
|
||||
|
||||
// 创建存储中存在的图片 key 集合,用于检测已删除的图片
|
||||
const s3ImageKeys = new Set(imageObjects.map((obj) => obj.key))
|
||||
|
||||
const runState = this.pluginManager.createRunState()
|
||||
const manifest: PhotoManifestItem[] = []
|
||||
const processingResults: ProcessPhotoResult[] = []
|
||||
let processedCount = 0
|
||||
let skippedCount = 0
|
||||
let newCount = 0
|
||||
let deletedCount = 0
|
||||
|
||||
if (imageObjects.length === 0) {
|
||||
logger.main.error('❌ 没有找到需要处理的照片')
|
||||
return {
|
||||
hasUpdates: false,
|
||||
newCount: 0,
|
||||
processedCount: 0,
|
||||
skippedCount: 0,
|
||||
deletedCount: 0,
|
||||
totalPhotos: 0,
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.emitPluginEvent(runState, 'beforeBuild', {
|
||||
options,
|
||||
})
|
||||
|
||||
// 筛选出实际需要处理的图片
|
||||
let tasksToProcess = await this.filterTaskImages(
|
||||
imageObjects,
|
||||
existingManifestMap,
|
||||
options,
|
||||
)
|
||||
this.logBuildStart()
|
||||
|
||||
logger.main.info(
|
||||
`存储中找到 ${imageObjects.length} 张照片,实际需要处理 ${tasksToProcess.length} 张`,
|
||||
)
|
||||
|
||||
// 为减少尾部长耗时,按文件大小降序处理(优先处理大文件)
|
||||
if (tasksToProcess.length > 1) {
|
||||
const beforeFirst = tasksToProcess[0]?.key
|
||||
tasksToProcess = tasksToProcess.sort(
|
||||
(a, b) => (b.size ?? 0) - (a.size ?? 0),
|
||||
// 读取现有的 manifest(如果存在)
|
||||
const existingManifest = await this.loadExistingManifest(options)
|
||||
const existingManifestItems = existingManifest.data
|
||||
const existingManifestMap = new Map(
|
||||
existingManifestItems.map((item) => [item.s3Key, item]),
|
||||
)
|
||||
if (beforeFirst !== tasksToProcess[0]?.key) {
|
||||
logger.main.info('已按文件大小降序重排处理队列')
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有任务需要处理,直接使用现有的 manifest
|
||||
if (tasksToProcess.length === 0) {
|
||||
logger.main.info('💡 没有需要处理的照片,使用现有 manifest')
|
||||
manifest.push(
|
||||
...existingManifestItems.filter((item) => s3ImageKeys.has(item.s3Key)),
|
||||
)
|
||||
} else {
|
||||
// 获取并发限制
|
||||
const concurrency =
|
||||
options.concurrencyLimit ?? this.config.options.defaultConcurrency
|
||||
|
||||
// 根据配置和实际任务数量选择处理模式
|
||||
const { useClusterMode } = this.config.performance.worker
|
||||
|
||||
// 如果实际任务数量较少,则不使用 cluster 模式
|
||||
const shouldUseCluster =
|
||||
useClusterMode && tasksToProcess.length >= concurrency * 2
|
||||
await this.emitPluginEvent(runState, 'afterManifestLoad', {
|
||||
options,
|
||||
manifest: existingManifest,
|
||||
manifestMap: existingManifestMap,
|
||||
})
|
||||
|
||||
logger.main.info(
|
||||
`开始${shouldUseCluster ? '多进程' : '并发'}处理任务,${shouldUseCluster ? '进程' : 'Worker'}数:${concurrency}${shouldUseCluster ? `,每进程并发:${this.config.performance.worker.workerConcurrency}` : ''}`,
|
||||
`现有 manifest 包含 ${existingManifestItems.length} 张照片`,
|
||||
)
|
||||
|
||||
logger.main.info('使用存储提供商:', this.config.storage.provider)
|
||||
|
||||
const storageManager = this.getStorageManager()
|
||||
|
||||
// 列出存储中的所有文件
|
||||
const allObjects = await storageManager.listAllFiles()
|
||||
logger.main.info(`存储中找到 ${allObjects.length} 个文件`)
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterAllFilesListed', {
|
||||
options,
|
||||
allObjects,
|
||||
})
|
||||
|
||||
// 检测 Live Photo 配对(如果启用)
|
||||
const livePhotoMap = await this.detectLivePhotos(allObjects)
|
||||
if (this.config.options.enableLivePhotoDetection) {
|
||||
logger.main.info(`检测到 ${livePhotoMap.size} 个 Live Photo`)
|
||||
}
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterLivePhotoDetection', {
|
||||
options,
|
||||
livePhotoMap,
|
||||
})
|
||||
|
||||
// 列出存储中的所有图片文件
|
||||
const imageObjects = await storageManager.listImages()
|
||||
logger.main.info(`存储中找到 ${imageObjects.length} 张照片`)
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterImagesListed', {
|
||||
options,
|
||||
imageObjects,
|
||||
})
|
||||
|
||||
if (imageObjects.length === 0) {
|
||||
logger.main.error('❌ 没有找到需要处理的照片')
|
||||
const result: BuilderResult = {
|
||||
hasUpdates: false,
|
||||
newCount: 0,
|
||||
processedCount: 0,
|
||||
skippedCount: 0,
|
||||
deletedCount: 0,
|
||||
totalPhotos: 0,
|
||||
}
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterBuild', {
|
||||
options,
|
||||
result,
|
||||
manifest,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 创建存储中存在的图片 key 集合,用于检测已删除的图片
|
||||
const s3ImageKeys = new Set(imageObjects.map((obj) => obj.key))
|
||||
|
||||
// 筛选出实际需要处理的图片
|
||||
let tasksToProcess = await this.filterTaskImages(
|
||||
imageObjects,
|
||||
existingManifestMap,
|
||||
options,
|
||||
)
|
||||
|
||||
// 为减少尾部长耗时,按文件大小降序处理(优先处理大文件)
|
||||
if (tasksToProcess.length > 1) {
|
||||
const beforeFirst = tasksToProcess[0]?.key
|
||||
tasksToProcess = tasksToProcess.sort(
|
||||
(a, b) => (b.size ?? 0) - (a.size ?? 0),
|
||||
)
|
||||
if (beforeFirst !== tasksToProcess[0]?.key) {
|
||||
logger.main.info('已按文件大小降序重排处理队列')
|
||||
}
|
||||
}
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterTasksPrepared', {
|
||||
options,
|
||||
tasks: tasksToProcess,
|
||||
totalImages: imageObjects.length,
|
||||
})
|
||||
|
||||
logger.main.info(
|
||||
`存储中找到 ${imageObjects.length} 张照片,实际需要处理 ${tasksToProcess.length} 张`,
|
||||
)
|
||||
|
||||
const processorOptions: PhotoProcessorOptions = {
|
||||
@@ -173,75 +209,115 @@ export class AfilmoryBuilder {
|
||||
isForceThumbnails: options.isForceThumbnails,
|
||||
}
|
||||
|
||||
let results: ProcessPhotoResult[]
|
||||
const concurrency =
|
||||
options.concurrencyLimit ?? this.config.options.defaultConcurrency
|
||||
const { useClusterMode } = this.config.performance.worker
|
||||
const shouldUseCluster =
|
||||
useClusterMode && tasksToProcess.length >= concurrency * 2
|
||||
|
||||
if (shouldUseCluster) {
|
||||
// 创建 Cluster 池(多进程模式)
|
||||
const clusterPool = new ClusterPool<ProcessPhotoResult>({
|
||||
concurrency,
|
||||
totalTasks: tasksToProcess.length,
|
||||
workerConcurrency: this.config.performance.worker.workerConcurrency,
|
||||
workerEnv: {
|
||||
FORCE_MODE: processorOptions.isForceMode.toString(),
|
||||
FORCE_MANIFEST: processorOptions.isForceManifest.toString(),
|
||||
FORCE_THUMBNAILS: processorOptions.isForceThumbnails.toString(),
|
||||
},
|
||||
sharedData: {
|
||||
existingManifestMap,
|
||||
livePhotoMap,
|
||||
imageObjects: tasksToProcess,
|
||||
builderConfig: this.getConfig(),
|
||||
},
|
||||
})
|
||||
await this.emitPluginEvent(runState, 'beforeProcessTasks', {
|
||||
options,
|
||||
tasks: tasksToProcess,
|
||||
processorOptions,
|
||||
mode: shouldUseCluster ? 'cluster' : 'worker',
|
||||
concurrency,
|
||||
})
|
||||
|
||||
// 执行多进程并发处理
|
||||
results = await clusterPool.execute()
|
||||
if (tasksToProcess.length === 0) {
|
||||
logger.main.info('💡 没有需要处理的照片,使用现有 manifest')
|
||||
for (const item of existingManifestItems) {
|
||||
if (!s3ImageKeys.has(item.s3Key)) continue
|
||||
|
||||
await this.emitPluginEvent(runState, 'beforeAddManifestItem', {
|
||||
options,
|
||||
item,
|
||||
pluginData: {},
|
||||
resultType: 'skipped',
|
||||
})
|
||||
|
||||
manifest.push(item)
|
||||
}
|
||||
} else {
|
||||
// 创建传统 Worker 池(主线程并发模式)
|
||||
const workerPool = new WorkerPool<ProcessPhotoResult>({
|
||||
concurrency,
|
||||
totalTasks: tasksToProcess.length,
|
||||
})
|
||||
logger.main.info(
|
||||
`开始${shouldUseCluster ? '多进程' : '并发'}处理任务,${shouldUseCluster ? '进程' : 'Worker'}数:${concurrency}${shouldUseCluster ? `,每进程并发:${this.config.performance.worker.workerConcurrency}` : ''}`,
|
||||
)
|
||||
|
||||
// 执行并发处理
|
||||
results = await workerPool.execute(async (taskIndex, workerId) => {
|
||||
const obj = tasksToProcess[taskIndex]
|
||||
let results: ProcessPhotoResult[]
|
||||
|
||||
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
|
||||
const legacyObj = {
|
||||
Key: obj.key,
|
||||
Size: obj.size,
|
||||
LastModified: obj.lastModified,
|
||||
ETag: obj.etag,
|
||||
}
|
||||
if (shouldUseCluster) {
|
||||
const clusterPool = new ClusterPool<ProcessPhotoResult>({
|
||||
concurrency,
|
||||
totalTasks: tasksToProcess.length,
|
||||
workerConcurrency: this.config.performance.worker.workerConcurrency,
|
||||
workerEnv: {
|
||||
FORCE_MODE: processorOptions.isForceMode.toString(),
|
||||
FORCE_MANIFEST: processorOptions.isForceManifest.toString(),
|
||||
FORCE_THUMBNAILS: processorOptions.isForceThumbnails.toString(),
|
||||
},
|
||||
sharedData: {
|
||||
existingManifestMap,
|
||||
livePhotoMap,
|
||||
imageObjects: tasksToProcess,
|
||||
builderConfig: this.getConfig(),
|
||||
},
|
||||
})
|
||||
|
||||
// 转换 Live Photo Map
|
||||
const legacyLivePhotoMap = new Map()
|
||||
for (const [key, value] of livePhotoMap) {
|
||||
legacyLivePhotoMap.set(key, {
|
||||
Key: value.key,
|
||||
Size: value.size,
|
||||
LastModified: value.lastModified,
|
||||
ETag: value.etag,
|
||||
})
|
||||
}
|
||||
results = await clusterPool.execute()
|
||||
} else {
|
||||
const workerPool = new WorkerPool<ProcessPhotoResult>({
|
||||
concurrency,
|
||||
totalTasks: tasksToProcess.length,
|
||||
})
|
||||
|
||||
return await processPhoto(
|
||||
legacyObj,
|
||||
taskIndex,
|
||||
workerId,
|
||||
tasksToProcess.length,
|
||||
existingManifestMap,
|
||||
legacyLivePhotoMap,
|
||||
processorOptions,
|
||||
this,
|
||||
)
|
||||
})
|
||||
}
|
||||
results = await workerPool.execute(async (taskIndex, workerId) => {
|
||||
const obj = tasksToProcess[taskIndex]
|
||||
|
||||
const legacyObj = {
|
||||
Key: obj.key,
|
||||
Size: obj.size,
|
||||
LastModified: obj.lastModified,
|
||||
ETag: obj.etag,
|
||||
}
|
||||
|
||||
const legacyLivePhotoMap = new Map()
|
||||
for (const [key, value] of livePhotoMap) {
|
||||
legacyLivePhotoMap.set(key, {
|
||||
Key: value.key,
|
||||
Size: value.size,
|
||||
LastModified: value.lastModified,
|
||||
ETag: value.etag,
|
||||
})
|
||||
}
|
||||
|
||||
return await processPhoto(
|
||||
legacyObj,
|
||||
taskIndex,
|
||||
workerId,
|
||||
tasksToProcess.length,
|
||||
existingManifestMap,
|
||||
legacyLivePhotoMap,
|
||||
processorOptions,
|
||||
this,
|
||||
{
|
||||
runState,
|
||||
builderOptions: options,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
processingResults.push(...results)
|
||||
|
||||
for (const result of results) {
|
||||
if (!result.item) continue
|
||||
|
||||
await this.emitPluginEvent(runState, 'beforeAddManifestItem', {
|
||||
options,
|
||||
item: result.item,
|
||||
pluginData: result.pluginData ?? {},
|
||||
resultType: result.type,
|
||||
})
|
||||
|
||||
// 统计结果并添加到 manifest
|
||||
for (const result of results) {
|
||||
if (result.item) {
|
||||
manifest.push(result.item)
|
||||
|
||||
switch (result.type) {
|
||||
@@ -260,50 +336,99 @@ export class AfilmoryBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加未处理但仍然存在的照片到 manifest
|
||||
for (const [key, item] of existingManifestMap) {
|
||||
if (s3ImageKeys.has(key) && !manifest.some((m) => m.s3Key === key)) {
|
||||
manifest.push(item)
|
||||
skippedCount++
|
||||
for (const [key, item] of existingManifestMap) {
|
||||
if (s3ImageKeys.has(key) && !manifest.some((m) => m.s3Key === key)) {
|
||||
await this.emitPluginEvent(runState, 'beforeAddManifestItem', {
|
||||
options,
|
||||
item,
|
||||
pluginData: {},
|
||||
resultType: 'skipped',
|
||||
})
|
||||
|
||||
manifest.push(item)
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测并处理已删除的图片
|
||||
deletedCount = await handleDeletedPhotos(manifest)
|
||||
|
||||
// 生成相机和镜头集合
|
||||
const cameras = this.generateCameraCollection(manifest)
|
||||
const lenses = this.generateLensCollection(manifest)
|
||||
|
||||
// 保存 manifest
|
||||
await saveManifest(manifest, cameras, lenses)
|
||||
|
||||
// 显示构建结果
|
||||
if (this.config.options.showDetailedStats) {
|
||||
this.logBuildResults(
|
||||
await this.emitPluginEvent(runState, 'afterProcessTasks', {
|
||||
options,
|
||||
tasks: tasksToProcess,
|
||||
results: processingResults,
|
||||
manifest,
|
||||
{
|
||||
stats: {
|
||||
newCount,
|
||||
processedCount,
|
||||
skippedCount,
|
||||
deletedCount,
|
||||
},
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 返回构建结果
|
||||
const hasUpdates = newCount > 0 || processedCount > 0 || deletedCount > 0
|
||||
return {
|
||||
hasUpdates,
|
||||
newCount,
|
||||
processedCount,
|
||||
skippedCount,
|
||||
deletedCount,
|
||||
totalPhotos: manifest.length,
|
||||
// 检测并处理已删除的图片
|
||||
deletedCount = await handleDeletedPhotos(manifest)
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterCleanup', {
|
||||
options,
|
||||
manifest,
|
||||
deletedCount,
|
||||
})
|
||||
|
||||
// 生成相机和镜头集合
|
||||
const cameras = this.generateCameraCollection(manifest)
|
||||
const lenses = this.generateLensCollection(manifest)
|
||||
|
||||
await this.emitPluginEvent(runState, 'beforeSaveManifest', {
|
||||
options,
|
||||
manifest,
|
||||
cameras,
|
||||
lenses,
|
||||
})
|
||||
|
||||
await saveManifest(manifest, cameras, lenses)
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterSaveManifest', {
|
||||
options,
|
||||
manifest,
|
||||
cameras,
|
||||
lenses,
|
||||
})
|
||||
|
||||
if (this.config.options.showDetailedStats) {
|
||||
this.logBuildResults(
|
||||
manifest,
|
||||
{
|
||||
newCount,
|
||||
processedCount,
|
||||
skippedCount,
|
||||
deletedCount,
|
||||
},
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const hasUpdates = newCount > 0 || processedCount > 0 || deletedCount > 0
|
||||
const result: BuilderResult = {
|
||||
hasUpdates,
|
||||
newCount,
|
||||
processedCount,
|
||||
skippedCount,
|
||||
deletedCount,
|
||||
totalPhotos: manifest.length,
|
||||
}
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterBuild', {
|
||||
options,
|
||||
result,
|
||||
manifest,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
await this.emitPluginEvent(runState, 'onError', {
|
||||
options,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +452,7 @@ export class AfilmoryBuilder {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
return await this.storageManager.detectLivePhotos(allObjects)
|
||||
return await this.getStorageManager().detectLivePhotos(allObjects)
|
||||
}
|
||||
|
||||
private logBuildStart(): void {
|
||||
@@ -387,6 +512,113 @@ export class AfilmoryBuilder {
|
||||
* 获取当前使用的存储管理器
|
||||
*/
|
||||
getStorageManager(): StorageManager {
|
||||
return this.ensureStorageManager()
|
||||
}
|
||||
|
||||
registerStorageProvider(
|
||||
provider: string,
|
||||
factory: StorageProviderFactory,
|
||||
): void {
|
||||
StorageFactory.registerProvider(provider, factory)
|
||||
|
||||
if (this.config.storage.provider === provider) {
|
||||
this.storageManager = null
|
||||
this.ensureStorageManager()
|
||||
}
|
||||
}
|
||||
|
||||
createPluginRunState(): PluginRunState {
|
||||
return this.pluginManager.createRunState()
|
||||
}
|
||||
|
||||
async emitPluginEvent<TEvent extends keyof BuilderPluginEventPayloads>(
|
||||
runState: PluginRunState,
|
||||
event: TEvent,
|
||||
payload: BuilderPluginEventPayloads[TEvent],
|
||||
): Promise<void> {
|
||||
await this.pluginManager.emit(this, runState, event, payload)
|
||||
}
|
||||
|
||||
async ensurePluginsReady(): Promise<void> {
|
||||
await this.pluginManager.ensureLoaded(this)
|
||||
}
|
||||
|
||||
private resolvePluginReferences(): BuilderPluginConfigEntry[] {
|
||||
const references: BuilderPluginConfigEntry[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const addReference = (ref: BuilderPluginConfigEntry) => {
|
||||
if (typeof ref === 'string') {
|
||||
if (seen.has(ref)) return
|
||||
seen.add(ref)
|
||||
references.push(ref)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPluginReferenceObject(ref)) {
|
||||
const key = ref.resolve
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
references.push(ref)
|
||||
return
|
||||
}
|
||||
|
||||
const pluginName = ref.name
|
||||
if (pluginName) {
|
||||
const key = `plugin:${pluginName}`
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
references.push(ref)
|
||||
}
|
||||
|
||||
const hasPluginWithName = (name: string): boolean => {
|
||||
return references.some((ref) => {
|
||||
if (typeof ref === 'string' || isPluginReferenceObject(ref)) {
|
||||
return false
|
||||
}
|
||||
return ref.name === name
|
||||
})
|
||||
}
|
||||
|
||||
for (const ref of this.config.plugins ?? []) {
|
||||
addReference(ref)
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.repo.enable &&
|
||||
!hasPluginWithName('afilmory:github-repo-sync')
|
||||
) {
|
||||
addReference('@afilmory/builder/plugins/github-repo-sync')
|
||||
}
|
||||
|
||||
const storagePluginByProvider: Record<string, string> = {
|
||||
s3: '@afilmory/builder/plugins/storage/s3',
|
||||
github: '@afilmory/builder/plugins/storage/github',
|
||||
eagle: '@afilmory/builder/plugins/storage/eagle',
|
||||
local: '@afilmory/builder/plugins/storage/local',
|
||||
}
|
||||
|
||||
const storageProvider = this.config.storage.provider
|
||||
const storagePlugin = storagePluginByProvider[storageProvider]
|
||||
if (storagePlugin) {
|
||||
const expectedName = `afilmory:storage:${storageProvider}`
|
||||
if (hasPluginWithName(expectedName)) {
|
||||
return references
|
||||
}
|
||||
addReference(storagePlugin)
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
private ensureStorageManager(): StorageManager {
|
||||
if (!this.storageManager) {
|
||||
this.storageManager = new StorageManager(this.config.storage)
|
||||
}
|
||||
|
||||
return this.storageManager
|
||||
}
|
||||
|
||||
@@ -394,7 +626,7 @@ export class AfilmoryBuilder {
|
||||
* 获取当前配置
|
||||
*/
|
||||
getConfig(): BuilderConfig {
|
||||
return cloneConfig(this.config)
|
||||
return clone(this.config)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,17 +751,3 @@ export class AfilmoryBuilder {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function cloneConfig<T>(value: T): T {
|
||||
const maybeStructuredClone = (
|
||||
globalThis as typeof globalThis & {
|
||||
structuredClone?: <U>(input: U) => U
|
||||
}
|
||||
).structuredClone
|
||||
|
||||
if (typeof maybeStructuredClone === 'function') {
|
||||
return maybeStructuredClone(value)
|
||||
}
|
||||
|
||||
return v8Deserialize(v8Serialize(value))
|
||||
}
|
||||
|
||||
@@ -2,122 +2,15 @@ import 'dotenv-expand/config'
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import cluster from 'node:cluster'
|
||||
import { existsSync } from 'node:fs'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
import { builderConfig } from '@builder'
|
||||
import { $ } from 'execa'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { AfilmoryBuilder } from './builder/index.js'
|
||||
import { loadBuilderConfig } from './config/index.js'
|
||||
import { logger } from './logger/index.js'
|
||||
import { workdir } from './path.js'
|
||||
import { runAsWorker } from './runAsWorker.js'
|
||||
|
||||
const cliBuilder = new AfilmoryBuilder(builderConfig)
|
||||
|
||||
/**
|
||||
* 推送更新后的 manifest 到远程仓库
|
||||
*/
|
||||
async function pushManifestToRemoteRepo(): Promise<boolean> {
|
||||
if (!builderConfig.repo.enable || !builderConfig.repo.token) {
|
||||
if (!builderConfig.repo.enable) {
|
||||
logger.main.info('🔧 远程仓库未启用,跳过推送')
|
||||
} else {
|
||||
logger.main.warn('⚠️ 未提供 Git Token,跳过推送到远程仓库')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const assetsGitDir = path.resolve(workdir, 'assets-git')
|
||||
|
||||
if (!existsSync(assetsGitDir)) {
|
||||
logger.main.error('❌ assets-git 目录不存在,无法推送')
|
||||
return false
|
||||
}
|
||||
|
||||
logger.main.info('📤 开始推送更新到远程仓库...')
|
||||
|
||||
// 配置 Git 用户身份(特别是在 CI 环境中)
|
||||
try {
|
||||
// 检查是否已配置用户身份
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git config user.name`
|
||||
} catch {
|
||||
// 如果没有配置,则设置默认的 CI 用户身份
|
||||
logger.main.info('🔧 配置 Git 用户身份(CI 环境)...')
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git config user.email "ci@afilmory.local"`
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git config user.name "Afilmory CI"`
|
||||
}
|
||||
|
||||
// 检查是否有变更
|
||||
const status = await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git status --porcelain`
|
||||
|
||||
if (!status.stdout.trim()) {
|
||||
logger.main.info('💡 没有变更需要推送')
|
||||
return false
|
||||
}
|
||||
|
||||
logger.main.info('📋 检测到以下变更:')
|
||||
logger.main.info(status.stdout)
|
||||
|
||||
// 配置 git 凭据
|
||||
const repoUrl = builderConfig.repo.url
|
||||
const { token } = builderConfig.repo
|
||||
|
||||
// 解析仓库 URL,添加 token
|
||||
let authenticatedUrl = repoUrl
|
||||
if (repoUrl.startsWith('https://github.com/')) {
|
||||
const urlWithoutProtocol = repoUrl.replace('https://', '')
|
||||
authenticatedUrl = `https://${token}@${urlWithoutProtocol}`
|
||||
}
|
||||
|
||||
// 设置远程仓库 URL(包含 token)
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git remote set-url origin ${authenticatedUrl}`
|
||||
|
||||
// 添加所有变更
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'inherit',
|
||||
})`git add .`
|
||||
|
||||
// 提交变更
|
||||
const commitMessage = `chore: update photos-manifest.json and thumbnails - ${new Date().toISOString()}`
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'inherit',
|
||||
})`git commit -m ${commitMessage}`
|
||||
|
||||
// 推送到远程仓库
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'inherit',
|
||||
})`git push origin HEAD`
|
||||
|
||||
logger.main.success('✅ 成功推送更新到远程仓库')
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.main.error('❌ 推送到远程仓库失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 检查是否作为 cluster worker 运行
|
||||
if (
|
||||
@@ -129,99 +22,10 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果配置了远程仓库,则使用远程仓库
|
||||
if (builderConfig.repo.enable) {
|
||||
// 拉取远程仓库
|
||||
logger.main.info('🔄 同步远程仓库...')
|
||||
|
||||
// 解析仓库 URL,添加 token
|
||||
let repoUrl = builderConfig.repo.url
|
||||
const { token } = builderConfig.repo
|
||||
if (token && repoUrl.startsWith('https://github.com/')) {
|
||||
const urlWithoutProtocol = repoUrl.replace('https://', '')
|
||||
repoUrl = `https://${token}@${urlWithoutProtocol}`
|
||||
}
|
||||
|
||||
const hasExist = existsSync(path.resolve(workdir, 'assets-git'))
|
||||
if (!hasExist) {
|
||||
logger.main.info('📥 克隆远程仓库...')
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`git clone ${repoUrl} assets-git`
|
||||
} else {
|
||||
logger.main.info('🔄 拉取远程仓库更新...')
|
||||
try {
|
||||
await $({
|
||||
cwd: path.resolve(workdir, 'assets-git'),
|
||||
stdio: 'inherit',
|
||||
})`git pull --rebase`
|
||||
} catch {
|
||||
logger.main.warn('⚠️ git pull 失败,尝试重置远程仓库...')
|
||||
logger.main.info('🗑️ 删除现有仓库目录...')
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf assets-git`
|
||||
logger.main.info('📥 重新克隆远程仓库...')
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`git clone ${repoUrl} assets-git`
|
||||
}
|
||||
}
|
||||
|
||||
// 确保远程仓库有必要的目录和文件
|
||||
const assetsGitDir = path.resolve(workdir, 'assets-git')
|
||||
const thumbnailsSourceDir = path.resolve(assetsGitDir, 'thumbnails')
|
||||
const manifestSourcePath = path.resolve(
|
||||
assetsGitDir,
|
||||
'photos-manifest.json',
|
||||
)
|
||||
|
||||
// 创建 thumbnails 目录(如果不存在)
|
||||
if (!existsSync(thumbnailsSourceDir)) {
|
||||
logger.main.info('📁 创建 thumbnails 目录...')
|
||||
await $({ cwd: assetsGitDir, stdio: 'inherit' })`mkdir -p thumbnails`
|
||||
}
|
||||
|
||||
// 创建空的 manifest 文件(如果不存在)
|
||||
if (!existsSync(manifestSourcePath)) {
|
||||
logger.main.info('📄 创建初始 manifest 文件...')
|
||||
const { CURRENT_MANIFEST_VERSION } = await import('./manifest/version.js')
|
||||
const initial = JSON.stringify(
|
||||
{ version: CURRENT_MANIFEST_VERSION, data: [] },
|
||||
null,
|
||||
2,
|
||||
)
|
||||
await fs.writeFile(manifestSourcePath, initial)
|
||||
}
|
||||
|
||||
// 删除 public/thumbnails 目录,并建立软连接到 assets-git/thumbnails
|
||||
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
|
||||
if (existsSync(thumbnailsDir)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
|
||||
}
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`ln -s ${thumbnailsSourceDir} ${thumbnailsDir}`
|
||||
|
||||
// 删除 src/data/photos-manifest.json,并建立软连接到 assets-git/photos-manifest.json
|
||||
const photosManifestPath = path.resolve(
|
||||
workdir,
|
||||
'src',
|
||||
'data',
|
||||
'photos-manifest.json',
|
||||
)
|
||||
if (existsSync(photosManifestPath)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -f ${photosManifestPath}`
|
||||
}
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`ln -s ${manifestSourcePath} ${photosManifestPath}`
|
||||
|
||||
logger.main.success('✅ 远程仓库同步完成')
|
||||
}
|
||||
|
||||
const builderConfig = await loadBuilderConfig({
|
||||
cwd: join(fileURLToPath(import.meta.url), '../../../..'),
|
||||
})
|
||||
const cliBuilder = new AfilmoryBuilder(builderConfig)
|
||||
process.title = 'photo-gallery-builder-main'
|
||||
|
||||
// 解析命令行参数
|
||||
@@ -339,23 +143,13 @@ async function main() {
|
||||
environmentCheck()
|
||||
|
||||
// 启动构建过程
|
||||
const buildResult = await cliBuilder.buildManifest({
|
||||
await cliBuilder.buildManifest({
|
||||
isForceMode,
|
||||
isForceManifest,
|
||||
isForceThumbnails,
|
||||
concurrencyLimit,
|
||||
})
|
||||
|
||||
// 如果启用了远程仓库,在构建完成后推送更新
|
||||
if (builderConfig.repo.enable) {
|
||||
if (buildResult.hasUpdates) {
|
||||
logger.main.info('🔄 检测到更新,推送到远程仓库...')
|
||||
await pushManifestToRemoteRepo()
|
||||
} else {
|
||||
logger.main.info('💡 没有更新需要推送到远程仓库')
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
58
packages/builder/src/config/defaults.ts
Normal file
58
packages/builder/src/config/defaults.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import os from 'node:os'
|
||||
|
||||
import type { BuilderConfig } from '../types/config.js'
|
||||
|
||||
export function createDefaultBuilderConfig(): BuilderConfig {
|
||||
return {
|
||||
repo: {
|
||||
enable: false,
|
||||
url: process.env.BUILDER_REPO_URL || '',
|
||||
token: process.env.GIT_TOKEN,
|
||||
},
|
||||
storage: {
|
||||
provider: 's3',
|
||||
bucket: process.env.S3_BUCKET_NAME,
|
||||
region: process.env.S3_REGION,
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
prefix: process.env.S3_PREFIX,
|
||||
customDomain: process.env.S3_CUSTOM_DOMAIN,
|
||||
excludeRegex: process.env.S3_EXCLUDE_REGEX,
|
||||
maxFileLimit: process.env.S3_MAX_FILE_LIMIT
|
||||
? Number.parseInt(process.env.S3_MAX_FILE_LIMIT, 10)
|
||||
: 1000,
|
||||
keepAlive: true,
|
||||
maxSockets: 64,
|
||||
connectionTimeoutMs: 5_000,
|
||||
socketTimeoutMs: 30_000,
|
||||
requestTimeoutMs: 20_000,
|
||||
idleTimeoutMs: 10_000,
|
||||
totalTimeoutMs: 60_000,
|
||||
retryMode: 'standard',
|
||||
maxAttempts: 3,
|
||||
downloadConcurrency: 16,
|
||||
},
|
||||
options: {
|
||||
defaultConcurrency: 10,
|
||||
enableLivePhotoDetection: true,
|
||||
showProgress: true,
|
||||
showDetailedStats: true,
|
||||
digestSuffixLength: 0,
|
||||
},
|
||||
logging: {
|
||||
verbose: false,
|
||||
level: 'info',
|
||||
outputToFile: false,
|
||||
},
|
||||
performance: {
|
||||
worker: {
|
||||
workerCount: os.cpus().length * 2,
|
||||
timeout: 30_000,
|
||||
useClusterMode: true,
|
||||
workerConcurrency: 2,
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
65
packages/builder/src/config/index.ts
Normal file
65
packages/builder/src/config/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { loadConfig } from 'c12'
|
||||
import consola from 'consola'
|
||||
import { merge } from 'es-toolkit'
|
||||
|
||||
import type { BuilderConfig, BuilderConfigInput } from '../types/config.js'
|
||||
import { clone } from '../utils/clone.js'
|
||||
import { createDefaultBuilderConfig } from './defaults.js'
|
||||
|
||||
export interface LoadBuilderConfigOptions {
|
||||
cwd?: string
|
||||
configFile?: string
|
||||
defaults?: BuilderConfig
|
||||
}
|
||||
|
||||
export function defineBuilderConfig(
|
||||
config: BuilderConfigInput | (() => BuilderConfigInput),
|
||||
): BuilderConfigInput {
|
||||
return typeof config === 'function' ? config() : config
|
||||
}
|
||||
|
||||
function normalizeBuilderConfig(
|
||||
defaults: BuilderConfig,
|
||||
input: BuilderConfigInput,
|
||||
): BuilderConfig {
|
||||
const base = clone(defaults)
|
||||
const merged = merge(base, input as Record<string, unknown>) as BuilderConfig
|
||||
|
||||
if (input.storage) {
|
||||
merged.storage = input.storage as BuilderConfig['storage']
|
||||
}
|
||||
|
||||
if (Array.isArray(input.plugins)) {
|
||||
merged.plugins = [...input.plugins]
|
||||
} else if (!Array.isArray(merged.plugins)) {
|
||||
merged.plugins = []
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function loadBuilderConfig(
|
||||
options: LoadBuilderConfigOptions = {},
|
||||
): Promise<BuilderConfig> {
|
||||
const defaults = options.defaults ?? createDefaultBuilderConfig()
|
||||
|
||||
const result = await loadConfig<BuilderConfigInput>({
|
||||
name: 'builder',
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
configFile: options.configFile,
|
||||
rcFile: false,
|
||||
dotenv: false,
|
||||
})
|
||||
|
||||
const userConfig = result.config ?? {}
|
||||
|
||||
const config = normalizeBuilderConfig(defaults, userConfig)
|
||||
|
||||
if (process.env.DEBUG === '1') {
|
||||
const logger = consola.withTag('CONFIG')
|
||||
logger.info('Using builder config from', result.configFile ?? 'defaults')
|
||||
logger.info(config)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -5,14 +5,15 @@ import { compressUint8Array } from '@afilmory/utils'
|
||||
import type { _Object } from '@aws-sdk/client-s3'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import type { AfilmoryBuilder } from '../builder/builder.js'
|
||||
import type { AfilmoryBuilder, BuilderOptions } from '../builder/builder.js'
|
||||
import {
|
||||
convertBmpToJpegSharpInstance,
|
||||
getImageMetadataWithSharp,
|
||||
isBitmap,
|
||||
preprocessImageBuffer,
|
||||
} from '../image/processor.js'
|
||||
import type { PhotoManifestItem } from '../types/photo.js'
|
||||
import type { PluginRunState } from '../plugins/manager.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import { shouldProcessPhoto } from './cache-manager.js'
|
||||
import {
|
||||
processExifData,
|
||||
@@ -36,6 +37,7 @@ export interface PhotoProcessingContext {
|
||||
existingItem: PhotoManifestItem | undefined
|
||||
livePhotoMap: Map<string, _Object>
|
||||
options: PhotoProcessorOptions
|
||||
pluginData: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,15 +257,22 @@ export async function executePhotoProcessingPipeline(
|
||||
export async function processPhotoWithPipeline(
|
||||
context: PhotoProcessingContext,
|
||||
builder: AfilmoryBuilder,
|
||||
runtime: { runState: PluginRunState; builderOptions: BuilderOptions },
|
||||
): Promise<{
|
||||
item: PhotoManifestItem | null
|
||||
type: 'new' | 'processed' | 'skipped' | 'failed'
|
||||
pluginData: Record<string, unknown>
|
||||
}> {
|
||||
const { photoKey, existingItem, obj, options } = context
|
||||
const loggers = getGlobalLoggers()
|
||||
|
||||
const photoId = await generatePhotoId(photoKey, builder)
|
||||
|
||||
await builder.emitPluginEvent(runtime.runState, 'beforePhotoProcess', {
|
||||
options: runtime.builderOptions,
|
||||
context,
|
||||
})
|
||||
|
||||
// 检查是否需要处理
|
||||
const { shouldProcess, reason } = await shouldProcessPhoto(
|
||||
photoId,
|
||||
@@ -274,7 +283,17 @@ export async function processPhotoWithPipeline(
|
||||
|
||||
if (!shouldProcess) {
|
||||
loggers.image.info(`⏭️ 跳过处理 (${reason}): ${photoKey}`)
|
||||
return { item: existingItem!, type: 'skipped' }
|
||||
const result = {
|
||||
item: existingItem ?? null,
|
||||
type: 'skipped' as const,
|
||||
pluginData: context.pluginData,
|
||||
}
|
||||
await builder.emitPluginEvent(runtime.runState, 'afterPhotoProcess', {
|
||||
options: runtime.builderOptions,
|
||||
context,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 记录处理原因
|
||||
@@ -285,15 +304,36 @@ export async function processPhotoWithPipeline(
|
||||
loggers.image.info(`🔄 更新照片 (${reason}):${photoKey}`)
|
||||
}
|
||||
|
||||
// 执行处理管道
|
||||
const processedItem = await executePhotoProcessingPipeline(context, builder)
|
||||
let processedItem: PhotoManifestItem | null = null
|
||||
let resultType: ProcessPhotoResult['type'] = isNewPhoto ? 'new' : 'processed'
|
||||
|
||||
if (!processedItem) {
|
||||
return { item: null, type: 'failed' }
|
||||
try {
|
||||
processedItem = await executePhotoProcessingPipeline(context, builder)
|
||||
if (!processedItem) {
|
||||
resultType = 'failed'
|
||||
}
|
||||
} catch (error) {
|
||||
await builder.emitPluginEvent(runtime.runState, 'photoProcessError', {
|
||||
options: runtime.builderOptions,
|
||||
context,
|
||||
error,
|
||||
})
|
||||
loggers.image.error(`❌ 处理过程中发生异常:${photoKey}`, error)
|
||||
processedItem = null
|
||||
resultType = 'failed'
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
item: processedItem,
|
||||
type: isNewPhoto ? 'new' : 'processed',
|
||||
type: resultType,
|
||||
pluginData: context.pluginData,
|
||||
}
|
||||
|
||||
await builder.emitPluginEvent(runtime.runState, 'afterPhotoProcess', {
|
||||
options: runtime.builderOptions,
|
||||
context,
|
||||
result,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { _Object } from '@aws-sdk/client-s3'
|
||||
|
||||
import type { AfilmoryBuilder } from '../builder/builder.js'
|
||||
import type { AfilmoryBuilder, BuilderOptions } from '../builder/builder.js'
|
||||
import { logger } from '../logger/index.js'
|
||||
import type { PluginRunState } from '../plugins/manager.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import type { PhotoProcessingContext } from './image-pipeline.js'
|
||||
import { processPhotoWithPipeline } from './image-pipeline.js'
|
||||
@@ -26,6 +27,10 @@ export async function processPhoto(
|
||||
livePhotoMap: Map<string, _Object>,
|
||||
options: PhotoProcessorOptions,
|
||||
builder: AfilmoryBuilder,
|
||||
pluginRuntime: {
|
||||
runState: PluginRunState
|
||||
builderOptions: BuilderOptions
|
||||
},
|
||||
): Promise<ProcessPhotoResult> {
|
||||
const key = obj.Key
|
||||
if (!key) {
|
||||
@@ -48,8 +53,9 @@ export async function processPhoto(
|
||||
existingItem,
|
||||
livePhotoMap,
|
||||
options,
|
||||
pluginData: {},
|
||||
}
|
||||
|
||||
// 使用处理管道
|
||||
return await processPhotoWithPipeline(context, builder)
|
||||
return await processPhotoWithPipeline(context, builder, pluginRuntime)
|
||||
}
|
||||
|
||||
244
packages/builder/src/plugins/github-repo-sync.ts
Normal file
244
packages/builder/src/plugins/github-repo-sync.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { $ } from 'execa'
|
||||
|
||||
import { workdir } from '../path.js'
|
||||
import type { BuilderPlugin } from './types.js'
|
||||
|
||||
const RUN_SHARED_ASSETS_DIR = 'assetsGitDir'
|
||||
|
||||
export interface GitHubRepoSyncPluginOptions {
|
||||
autoPush?: boolean
|
||||
}
|
||||
|
||||
export default function githubRepoSyncPlugin(
|
||||
options: GitHubRepoSyncPluginOptions = {},
|
||||
): BuilderPlugin {
|
||||
const autoPush = options.autoPush ?? true
|
||||
|
||||
return {
|
||||
name: 'afilmory:github-repo-sync',
|
||||
hooks: {
|
||||
beforeBuild: async (context) => {
|
||||
if (!context.config.repo.enable) {
|
||||
return
|
||||
}
|
||||
|
||||
const { logger } = context
|
||||
const { repo } = context.config
|
||||
|
||||
if (!repo.url) {
|
||||
logger.main.warn('⚠️ 未配置远程仓库地址,跳过同步')
|
||||
return
|
||||
}
|
||||
const assetsGitDir = path.resolve(workdir, 'assets-git')
|
||||
|
||||
context.runShared.set(RUN_SHARED_ASSETS_DIR, assetsGitDir)
|
||||
|
||||
logger.main.info('🔄 同步远程仓库...')
|
||||
|
||||
const repoUrl = buildAuthenticatedRepoUrl(repo.url, repo.token)
|
||||
|
||||
if (!existsSync(assetsGitDir)) {
|
||||
logger.main.info('📥 克隆远程仓库...')
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`git clone ${repoUrl} assets-git`
|
||||
} else {
|
||||
logger.main.info('🔄 拉取远程仓库更新...')
|
||||
try {
|
||||
await $({ cwd: assetsGitDir, stdio: 'inherit' })`git pull --rebase`
|
||||
} catch {
|
||||
logger.main.warn('⚠️ git pull 失败,尝试重新克隆远程仓库...')
|
||||
logger.main.info('🗑️ 删除现有仓库目录...')
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf assets-git`
|
||||
logger.main.info('📥 重新克隆远程仓库...')
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`git clone ${repoUrl} assets-git`
|
||||
}
|
||||
}
|
||||
|
||||
await prepareRepositoryLayout({ assetsGitDir, logger })
|
||||
logger.main.success('✅ 远程仓库同步完成')
|
||||
},
|
||||
afterBuild: async (context) => {
|
||||
if (!autoPush || !context.config.repo.enable) {
|
||||
return
|
||||
}
|
||||
|
||||
const { result } = context.payload
|
||||
const assetsGitDir = context.runShared.get(RUN_SHARED_ASSETS_DIR) as
|
||||
| string
|
||||
| undefined
|
||||
|
||||
if (!assetsGitDir) {
|
||||
context.logger.main.warn('⚠️ 未找到仓库目录,跳过推送')
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.hasUpdates) {
|
||||
context.logger.main.info('💡 没有更新需要推送到远程仓库')
|
||||
return
|
||||
}
|
||||
|
||||
await pushUpdatesToRemoteRepo({
|
||||
assetsGitDir,
|
||||
logger: context.logger,
|
||||
repoConfig: context.config.repo,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface PrepareRepositoryLayoutOptions {
|
||||
assetsGitDir: string
|
||||
logger: typeof import('../logger/index.js').logger
|
||||
}
|
||||
|
||||
async function prepareRepositoryLayout({
|
||||
assetsGitDir,
|
||||
logger,
|
||||
}: PrepareRepositoryLayoutOptions): Promise<void> {
|
||||
const thumbnailsSourceDir = path.resolve(assetsGitDir, 'thumbnails')
|
||||
const manifestSourcePath = path.resolve(assetsGitDir, 'photos-manifest.json')
|
||||
|
||||
if (!existsSync(thumbnailsSourceDir)) {
|
||||
logger.main.info('📁 创建 thumbnails 目录...')
|
||||
await $({ cwd: assetsGitDir, stdio: 'inherit' })`mkdir -p thumbnails`
|
||||
}
|
||||
|
||||
if (!existsSync(manifestSourcePath)) {
|
||||
logger.main.info('📄 创建初始 manifest 文件...')
|
||||
const { CURRENT_MANIFEST_VERSION } = await import('../manifest/version.js')
|
||||
const initial = JSON.stringify(
|
||||
{ version: CURRENT_MANIFEST_VERSION, data: [] },
|
||||
null,
|
||||
2,
|
||||
)
|
||||
await fs.writeFile(manifestSourcePath, initial)
|
||||
}
|
||||
|
||||
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
|
||||
if (existsSync(thumbnailsDir)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
|
||||
}
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`ln -s ${thumbnailsSourceDir} ${thumbnailsDir}`
|
||||
|
||||
const photosManifestPath = path.resolve(
|
||||
workdir,
|
||||
'src',
|
||||
'data',
|
||||
'photos-manifest.json',
|
||||
)
|
||||
if (existsSync(photosManifestPath)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -f ${photosManifestPath}`
|
||||
}
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`ln -s ${manifestSourcePath} ${photosManifestPath}`
|
||||
}
|
||||
|
||||
interface PushRemoteOptions {
|
||||
assetsGitDir: string
|
||||
logger: typeof import('../logger/index.js').logger
|
||||
repoConfig: {
|
||||
enable: boolean
|
||||
url: string
|
||||
token?: string
|
||||
}
|
||||
}
|
||||
|
||||
async function pushUpdatesToRemoteRepo({
|
||||
assetsGitDir,
|
||||
logger,
|
||||
repoConfig,
|
||||
}: PushRemoteOptions): Promise<void> {
|
||||
if (!repoConfig.url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!repoConfig.token) {
|
||||
logger.main.warn('⚠️ 未提供 Git Token,跳过推送到远程仓库')
|
||||
return
|
||||
}
|
||||
|
||||
logger.main.info('📤 开始推送更新到远程仓库...')
|
||||
|
||||
await ensureGitUserConfigured(assetsGitDir)
|
||||
|
||||
const status = await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git status --porcelain`
|
||||
|
||||
if (!status.stdout.trim()) {
|
||||
logger.main.info('💡 没有变更需要推送')
|
||||
return
|
||||
}
|
||||
|
||||
logger.main.info('📋 检测到以下变更:')
|
||||
logger.main.info(status.stdout)
|
||||
|
||||
const authenticatedUrl = buildAuthenticatedRepoUrl(
|
||||
repoConfig.url,
|
||||
repoConfig.token,
|
||||
)
|
||||
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git remote set-url origin ${authenticatedUrl}`
|
||||
await $({ cwd: assetsGitDir, stdio: 'inherit' })`git add .`
|
||||
|
||||
const commitMessage = `chore: update photos-manifest.json and thumbnails - ${new Date().toISOString()}`
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'inherit',
|
||||
})`git commit -m ${commitMessage}`
|
||||
await $({ cwd: assetsGitDir, stdio: 'inherit' })`git push origin HEAD`
|
||||
|
||||
logger.main.success('✅ 成功推送更新到远程仓库')
|
||||
}
|
||||
|
||||
async function ensureGitUserConfigured(assetsGitDir: string): Promise<void> {
|
||||
try {
|
||||
await $({ cwd: assetsGitDir, stdio: 'pipe' })`git config user.name`
|
||||
} catch {
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git config user.email "ci@afilmory.local"`
|
||||
await $({
|
||||
cwd: assetsGitDir,
|
||||
stdio: 'pipe',
|
||||
})`git config user.name "Afilmory CI"`
|
||||
}
|
||||
}
|
||||
|
||||
function buildAuthenticatedRepoUrl(url: string, token?: string): string {
|
||||
if (!token) return url
|
||||
|
||||
if (url.startsWith('https://github.com/')) {
|
||||
const urlWithoutProtocol = url.replace('https://', '')
|
||||
return `https://${token}@${urlWithoutProtocol}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export const plugin = githubRepoSyncPlugin
|
||||
export function createGitHubRepoSyncPlugin(
|
||||
options?: GitHubRepoSyncPluginOptions,
|
||||
): BuilderPlugin {
|
||||
return githubRepoSyncPlugin(options)
|
||||
}
|
||||
165
packages/builder/src/plugins/loader.ts
Normal file
165
packages/builder/src/plugins/loader.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import type {
|
||||
BuilderPlugin,
|
||||
BuilderPluginConfigEntry,
|
||||
BuilderPluginHooks,
|
||||
BuilderPluginReference,
|
||||
} from './types.js'
|
||||
import { isPluginReferenceObject } from './types.js'
|
||||
|
||||
const requireResolver = createRequire(import.meta.url)
|
||||
|
||||
export interface LoadedPluginDefinition {
|
||||
name: string
|
||||
hooks: BuilderPluginHooks
|
||||
pluginOptions: unknown
|
||||
}
|
||||
|
||||
interface NormalizedDescriptor {
|
||||
specifier: string
|
||||
name?: string
|
||||
options?: unknown
|
||||
}
|
||||
|
||||
function normalizeDescriptor(
|
||||
ref: BuilderPluginReference,
|
||||
): NormalizedDescriptor {
|
||||
if (typeof ref === 'string') {
|
||||
return { specifier: ref }
|
||||
}
|
||||
|
||||
return {
|
||||
specifier: ref.resolve,
|
||||
name: ref.name,
|
||||
options: ref.options,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSpecifier(
|
||||
specifier: string,
|
||||
baseDir: string,
|
||||
): { resolvedPath: string } {
|
||||
const isLocal =
|
||||
specifier.startsWith('.') ||
|
||||
specifier.startsWith('/') ||
|
||||
specifier.startsWith('file:')
|
||||
|
||||
if (isLocal) {
|
||||
const resolvedPath = specifier.startsWith('file:')
|
||||
? specifier
|
||||
: path.resolve(baseDir, specifier)
|
||||
return { resolvedPath }
|
||||
}
|
||||
|
||||
const resolvedPath = requireResolver.resolve(specifier, {
|
||||
paths: [baseDir],
|
||||
})
|
||||
|
||||
return { resolvedPath }
|
||||
}
|
||||
|
||||
async function importModule(resolvedPath: string): Promise<unknown> {
|
||||
if (resolvedPath.startsWith('file:')) {
|
||||
return await import(resolvedPath)
|
||||
}
|
||||
|
||||
const url = pathToFileURL(resolvedPath).href
|
||||
return await import(url)
|
||||
}
|
||||
|
||||
async function instantiatePlugin(
|
||||
exportedValue: unknown,
|
||||
options?: unknown,
|
||||
): Promise<BuilderPlugin> {
|
||||
const picked = [
|
||||
(exportedValue as { default?: unknown })?.default,
|
||||
(exportedValue as { plugin?: unknown }).plugin,
|
||||
(exportedValue as { createPlugin?: unknown }).createPlugin,
|
||||
exportedValue,
|
||||
].find(Boolean)
|
||||
|
||||
if (typeof picked === 'function') {
|
||||
const result = await Promise.resolve((picked as Function)(options))
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error('Plugin factory must return an object')
|
||||
}
|
||||
return result as BuilderPlugin
|
||||
}
|
||||
|
||||
if (!picked || typeof picked !== 'object') {
|
||||
throw new Error('Unsupported plugin export format')
|
||||
}
|
||||
|
||||
return picked as BuilderPlugin
|
||||
}
|
||||
|
||||
function normalizeHooks(plugin: BuilderPlugin): BuilderPluginHooks {
|
||||
const hooks: BuilderPluginHooks = {}
|
||||
|
||||
if (plugin.hooks && typeof plugin.hooks === 'object') {
|
||||
Object.assign(hooks, plugin.hooks)
|
||||
}
|
||||
|
||||
const candidates = Object.entries(plugin).filter(
|
||||
([key]) => key !== 'name' && key !== 'hooks',
|
||||
) as Array<[string, unknown]>
|
||||
|
||||
for (const [key, value] of candidates) {
|
||||
if (typeof value === 'function' && !(key in hooks)) {
|
||||
;(hooks as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
export async function loadPlugins(
|
||||
entries: BuilderPluginConfigEntry[] = [],
|
||||
options: { baseDir: string },
|
||||
): Promise<LoadedPluginDefinition[]> {
|
||||
if (entries.length === 0) return []
|
||||
|
||||
const { baseDir } = options
|
||||
|
||||
const results: LoadedPluginDefinition[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === 'string' || isPluginReferenceObject(entry)) {
|
||||
const descriptor = normalizeDescriptor(entry)
|
||||
const { resolvedPath } = resolveSpecifier(descriptor.specifier, baseDir)
|
||||
|
||||
const mod = await importModule(resolvedPath)
|
||||
const plugin = await instantiatePlugin(mod, descriptor.options)
|
||||
const hooks = normalizeHooks(plugin)
|
||||
|
||||
const name =
|
||||
plugin.name ||
|
||||
descriptor.name ||
|
||||
(descriptor.specifier.startsWith('.')
|
||||
? path.basename(descriptor.specifier)
|
||||
: descriptor.specifier)
|
||||
|
||||
results.push({
|
||||
name,
|
||||
hooks,
|
||||
pluginOptions: descriptor.options,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = await instantiatePlugin(entry)
|
||||
const hooks = normalizeHooks(plugin)
|
||||
const name = plugin.name || `inline-plugin-${results.length}`
|
||||
|
||||
results.push({
|
||||
name,
|
||||
hooks,
|
||||
pluginOptions: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
122
packages/builder/src/plugins/manager.ts
Normal file
122
packages/builder/src/plugins/manager.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { AfilmoryBuilder } from '../builder/builder.js'
|
||||
import { logger } from '../logger/index.js'
|
||||
import { loadPlugins } from './loader.js'
|
||||
import type {
|
||||
BuilderPluginConfigEntry,
|
||||
BuilderPluginEvent,
|
||||
BuilderPluginEventPayloads,
|
||||
BuilderPluginHookContext,
|
||||
} from './types.js'
|
||||
|
||||
export type PluginRunState = Map<string, Map<string, unknown>>
|
||||
|
||||
export class PluginManager {
|
||||
private readonly entries: BuilderPluginConfigEntry[]
|
||||
private readonly baseDir: string
|
||||
private plugins: Awaited<ReturnType<typeof loadPlugins>> = []
|
||||
private loadPromise: Promise<void> | null = null
|
||||
|
||||
constructor(
|
||||
entries: BuilderPluginConfigEntry[] = [],
|
||||
options: { baseDir?: string } = {},
|
||||
) {
|
||||
this.entries = entries
|
||||
this.baseDir = options.baseDir ?? process.cwd()
|
||||
}
|
||||
|
||||
hasPlugins(): boolean {
|
||||
return this.entries.length > 0
|
||||
}
|
||||
|
||||
createRunState(): PluginRunState {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
async ensureLoaded(builder: AfilmoryBuilder): Promise<void> {
|
||||
if (this.plugins.length > 0 || this.loadPromise) {
|
||||
await this.loadPromise
|
||||
return
|
||||
}
|
||||
|
||||
if (this.entries.length === 0) {
|
||||
this.plugins = []
|
||||
return
|
||||
}
|
||||
|
||||
this.loadPromise = (async () => {
|
||||
this.plugins = await loadPlugins(this.entries, {
|
||||
baseDir: this.baseDir,
|
||||
})
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
const initHook = plugin.hooks.onInit
|
||||
if (!initHook) continue
|
||||
|
||||
try {
|
||||
await initHook({
|
||||
builder,
|
||||
config: builder.getConfig(),
|
||||
logger,
|
||||
registerStorageProvider:
|
||||
builder.registerStorageProvider.bind(builder),
|
||||
pluginOptions: plugin.pluginOptions,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.main.error(
|
||||
`[Builder Plugin] 初始化插件 "${plugin.name}" 失败`,
|
||||
error,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
await this.loadPromise
|
||||
}
|
||||
|
||||
async emit<TEvent extends BuilderPluginEvent>(
|
||||
builder: AfilmoryBuilder,
|
||||
runState: PluginRunState,
|
||||
event: TEvent,
|
||||
payload: BuilderPluginEventPayloads[TEvent],
|
||||
): Promise<void> {
|
||||
if (this.plugins.length === 0) return
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
const hook = plugin.hooks[event] as
|
||||
| ((context: BuilderPluginHookContext<TEvent>) => void | Promise<void>)
|
||||
| undefined
|
||||
if (!hook) continue
|
||||
|
||||
const sharedKey = plugin.name
|
||||
let shared = runState.get(sharedKey)
|
||||
if (!shared) {
|
||||
shared = new Map()
|
||||
runState.set(sharedKey, shared)
|
||||
}
|
||||
|
||||
const context: BuilderPluginHookContext<TEvent> = {
|
||||
builder,
|
||||
config: builder.getConfig(),
|
||||
logger,
|
||||
registerStorageProvider: builder.registerStorageProvider.bind(builder),
|
||||
options: payload.options,
|
||||
pluginName: plugin.name,
|
||||
pluginOptions: plugin.pluginOptions,
|
||||
runShared: shared,
|
||||
event,
|
||||
payload,
|
||||
}
|
||||
|
||||
try {
|
||||
await hook(context)
|
||||
} catch (error) {
|
||||
logger.main.error(
|
||||
`[Builder Plugin] 插件 "${plugin.name}" 在 ${event} 钩子中抛出错误`,
|
||||
error,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/builder/src/plugins/storage/eagle.ts
Normal file
24
packages/builder/src/plugins/storage/eagle.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { EagleConfig } from '../../storage/interfaces.js'
|
||||
import { EagleStorageProvider } from '../../storage/providers/eagle-provider.js'
|
||||
import type { BuilderPlugin } from '../types.js'
|
||||
|
||||
export interface EagleStoragePluginOptions {
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export default function eagleStoragePlugin(
|
||||
options: EagleStoragePluginOptions = {},
|
||||
): BuilderPlugin {
|
||||
const providerName = options.provider ?? 'eagle'
|
||||
|
||||
return {
|
||||
name: `afilmory:storage:${providerName}`,
|
||||
hooks: {
|
||||
onInit: ({ registerStorageProvider }) => {
|
||||
registerStorageProvider(providerName, (config) => {
|
||||
return new EagleStorageProvider(config as EagleConfig)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
24
packages/builder/src/plugins/storage/github.ts
Normal file
24
packages/builder/src/plugins/storage/github.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { GitHubConfig } from '../../storage/interfaces.js'
|
||||
import { GitHubStorageProvider } from '../../storage/providers/github-provider.js'
|
||||
import type { BuilderPlugin } from '../types.js'
|
||||
|
||||
export interface GitHubStoragePluginOptions {
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export default function githubStoragePlugin(
|
||||
options: GitHubStoragePluginOptions = {},
|
||||
): BuilderPlugin {
|
||||
const providerName = options.provider ?? 'github'
|
||||
|
||||
return {
|
||||
name: `afilmory:storage:${providerName}`,
|
||||
hooks: {
|
||||
onInit: ({ registerStorageProvider }) => {
|
||||
registerStorageProvider(providerName, (config) => {
|
||||
return new GitHubStorageProvider(config as GitHubConfig)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
24
packages/builder/src/plugins/storage/local.ts
Normal file
24
packages/builder/src/plugins/storage/local.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { LocalConfig } from '../../storage/interfaces.js'
|
||||
import { LocalStorageProvider } from '../../storage/providers/local-provider.js'
|
||||
import type { BuilderPlugin } from '../types.js'
|
||||
|
||||
export interface LocalStoragePluginOptions {
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export default function localStoragePlugin(
|
||||
options: LocalStoragePluginOptions = {},
|
||||
): BuilderPlugin {
|
||||
const providerName = options.provider ?? 'local'
|
||||
|
||||
return {
|
||||
name: `afilmory:storage:${providerName}`,
|
||||
hooks: {
|
||||
onInit: ({ registerStorageProvider }) => {
|
||||
registerStorageProvider(providerName, (config) => {
|
||||
return new LocalStorageProvider(config as LocalConfig)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
24
packages/builder/src/plugins/storage/s3.ts
Normal file
24
packages/builder/src/plugins/storage/s3.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { S3Config } from '../../storage/interfaces.js'
|
||||
import { S3StorageProvider } from '../../storage/providers/s3-provider.js'
|
||||
import type { BuilderPlugin } from '../types.js'
|
||||
|
||||
export interface S3StoragePluginOptions {
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export default function s3StoragePlugin(
|
||||
options: S3StoragePluginOptions = {},
|
||||
): BuilderPlugin {
|
||||
const providerName = options.provider ?? 's3'
|
||||
|
||||
return {
|
||||
name: `afilmory:storage:${providerName}`,
|
||||
hooks: {
|
||||
onInit: ({ registerStorageProvider }) => {
|
||||
registerStorageProvider(providerName, (config) => {
|
||||
return new S3StorageProvider(config as S3Config)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
192
packages/builder/src/plugins/types.ts
Normal file
192
packages/builder/src/plugins/types.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
AfilmoryBuilder,
|
||||
BuilderOptions,
|
||||
BuilderResult,
|
||||
} from '../builder/builder.js'
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import type { PhotoProcessingContext } from '../photo/image-pipeline.js'
|
||||
import type { PhotoProcessorOptions } from '../photo/processor.js'
|
||||
import type { StorageObject } from '../storage/interfaces.js'
|
||||
import type { BuilderConfig } from '../types/config.js'
|
||||
import type {
|
||||
AfilmoryManifest,
|
||||
CameraInfo,
|
||||
LensInfo,
|
||||
} from '../types/manifest.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
|
||||
export type BuilderPluginReference =
|
||||
| string
|
||||
| {
|
||||
resolve: string
|
||||
/**
|
||||
* Optional name override for the plugin. Falls back to the resolved name.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Arbitrary configuration passed to the plugin factory.
|
||||
*/
|
||||
options?: unknown
|
||||
}
|
||||
|
||||
export type BuilderPluginConfigEntry = BuilderPluginReference | BuilderPlugin
|
||||
|
||||
export interface BuilderPluginInitContext {
|
||||
builder: AfilmoryBuilder
|
||||
config: BuilderConfig
|
||||
logger: Logger
|
||||
registerStorageProvider: AfilmoryBuilder['registerStorageProvider']
|
||||
/**
|
||||
* Options provided in the configuration for this plugin.
|
||||
*/
|
||||
pluginOptions: unknown
|
||||
}
|
||||
|
||||
export interface BuilderPluginEventPayloads {
|
||||
beforeBuild: {
|
||||
options: BuilderOptions
|
||||
}
|
||||
beforePhotoProcess: {
|
||||
options: BuilderOptions
|
||||
context: PhotoProcessingContext
|
||||
}
|
||||
afterPhotoProcess: {
|
||||
options: BuilderOptions
|
||||
context: PhotoProcessingContext
|
||||
result: {
|
||||
type: ProcessPhotoResult['type']
|
||||
item: PhotoManifestItem | null
|
||||
pluginData: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
photoProcessError: {
|
||||
options: BuilderOptions
|
||||
context: PhotoProcessingContext
|
||||
error: unknown
|
||||
}
|
||||
afterManifestLoad: {
|
||||
options: BuilderOptions
|
||||
manifest: AfilmoryManifest
|
||||
manifestMap: Map<string, PhotoManifestItem>
|
||||
}
|
||||
afterAllFilesListed: {
|
||||
options: BuilderOptions
|
||||
allObjects: StorageObject[]
|
||||
}
|
||||
afterLivePhotoDetection: {
|
||||
options: BuilderOptions
|
||||
livePhotoMap: Map<string, StorageObject>
|
||||
}
|
||||
afterImagesListed: {
|
||||
options: BuilderOptions
|
||||
imageObjects: StorageObject[]
|
||||
}
|
||||
afterTasksPrepared: {
|
||||
options: BuilderOptions
|
||||
tasks: StorageObject[]
|
||||
totalImages: number
|
||||
}
|
||||
beforeProcessTasks: {
|
||||
options: BuilderOptions
|
||||
tasks: StorageObject[]
|
||||
processorOptions: PhotoProcessorOptions
|
||||
mode: 'cluster' | 'worker'
|
||||
concurrency: number
|
||||
}
|
||||
afterProcessTasks: {
|
||||
options: BuilderOptions
|
||||
tasks: StorageObject[]
|
||||
results: ProcessPhotoResult[]
|
||||
manifest: PhotoManifestItem[]
|
||||
stats: {
|
||||
newCount: number
|
||||
processedCount: number
|
||||
skippedCount: number
|
||||
}
|
||||
}
|
||||
afterCleanup: {
|
||||
options: BuilderOptions
|
||||
manifest: PhotoManifestItem[]
|
||||
deletedCount: number
|
||||
}
|
||||
beforeAddManifestItem: {
|
||||
options: BuilderOptions
|
||||
item: PhotoManifestItem
|
||||
pluginData: Record<string, unknown>
|
||||
resultType: ProcessPhotoResult['type']
|
||||
}
|
||||
beforeSaveManifest: {
|
||||
options: BuilderOptions
|
||||
manifest: PhotoManifestItem[]
|
||||
cameras: CameraInfo[]
|
||||
lenses: LensInfo[]
|
||||
}
|
||||
afterSaveManifest: {
|
||||
options: BuilderOptions
|
||||
manifest: PhotoManifestItem[]
|
||||
cameras: CameraInfo[]
|
||||
lenses: LensInfo[]
|
||||
}
|
||||
afterBuild: {
|
||||
options: BuilderOptions
|
||||
result: BuilderResult
|
||||
manifest: PhotoManifestItem[]
|
||||
}
|
||||
onError: {
|
||||
options: BuilderOptions
|
||||
error: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type BuilderPluginEvent = keyof BuilderPluginEventPayloads
|
||||
|
||||
export interface BuilderPluginHookContext<TEvent extends BuilderPluginEvent> {
|
||||
builder: AfilmoryBuilder
|
||||
config: BuilderConfig
|
||||
logger: Logger
|
||||
options: BuilderOptions
|
||||
registerStorageProvider: AfilmoryBuilder['registerStorageProvider']
|
||||
/**
|
||||
* Name of the plugin handling the current hook.
|
||||
*/
|
||||
pluginName: string
|
||||
/**
|
||||
* Options associated with the plugin, if any.
|
||||
*/
|
||||
pluginOptions: unknown
|
||||
/**
|
||||
* A mutable map scoped to the current build run, allowing plugins
|
||||
* to persist information between lifecycle hooks.
|
||||
*/
|
||||
runShared: Map<string, unknown>
|
||||
event: TEvent
|
||||
payload: BuilderPluginEventPayloads[TEvent]
|
||||
}
|
||||
|
||||
export type BuilderPluginHook<TEvent extends BuilderPluginEvent> = (
|
||||
context: BuilderPluginHookContext<TEvent>,
|
||||
) => void | Promise<void>
|
||||
|
||||
export type BuilderPluginLifecycleHooks = Partial<{
|
||||
[Event in BuilderPluginEvent]: BuilderPluginHook<Event>
|
||||
}>
|
||||
|
||||
export interface BuilderPluginHooks extends BuilderPluginLifecycleHooks {
|
||||
onInit?: (context: BuilderPluginInitContext) => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface BuilderPlugin {
|
||||
name?: string
|
||||
hooks?: BuilderPluginHooks
|
||||
}
|
||||
|
||||
export type BuilderPluginFactory =
|
||||
| BuilderPlugin
|
||||
| (() => BuilderPlugin | Promise<BuilderPlugin>)
|
||||
| ((options: unknown) => BuilderPlugin | Promise<BuilderPlugin>)
|
||||
|
||||
export function isPluginReferenceObject(
|
||||
value: BuilderPluginConfigEntry,
|
||||
): value is Exclude<BuilderPluginReference, string> {
|
||||
return typeof value === 'object' && value !== null && 'resolve' in value
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import process from 'node:process'
|
||||
import { deserialize } from 'node:v8'
|
||||
|
||||
import type { BuilderOptions } from './builder/builder.js'
|
||||
import { AfilmoryBuilder } from './builder/builder.js'
|
||||
import type { PluginRunState } from './plugins/manager.js'
|
||||
import type { StorageObject } from './storage/interfaces'
|
||||
import type { BuilderConfig } from './types/config.js'
|
||||
import type { PhotoManifestItem } from './types/photo'
|
||||
@@ -40,6 +42,7 @@ export async function runAsWorker() {
|
||||
let existingManifestMap: Map<string, PhotoManifestItem>
|
||||
let livePhotoMap: Map<string, StorageObject>
|
||||
let builder: AfilmoryBuilder
|
||||
let pluginRunState: PluginRunState
|
||||
|
||||
// 初始化函数,从主进程接收共享数据
|
||||
const initializeWorker = async (
|
||||
@@ -56,6 +59,8 @@ export async function runAsWorker() {
|
||||
existingManifestMap = sharedData.existingManifestMap
|
||||
livePhotoMap = sharedData.livePhotoMap
|
||||
builder = new AfilmoryBuilder(sharedData.builderConfig)
|
||||
await builder.ensurePluginsReady()
|
||||
pluginRunState = builder.createPluginRunState()
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
@@ -104,6 +109,13 @@ export async function runAsWorker() {
|
||||
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
|
||||
}
|
||||
|
||||
const builderOptions: BuilderOptions = {
|
||||
isForceMode: processorOptions.isForceMode,
|
||||
isForceManifest: processorOptions.isForceManifest,
|
||||
isForceThumbnails: processorOptions.isForceThumbnails,
|
||||
concurrencyLimit: undefined,
|
||||
}
|
||||
|
||||
// 处理照片
|
||||
const result = await processPhoto(
|
||||
legacyObj,
|
||||
@@ -114,6 +126,10 @@ export async function runAsWorker() {
|
||||
legacyLivePhotoMap,
|
||||
processorOptions,
|
||||
builder,
|
||||
{
|
||||
runState: pluginRunState,
|
||||
builderOptions,
|
||||
},
|
||||
)
|
||||
|
||||
// 发送结果回主进程
|
||||
@@ -150,6 +166,17 @@ export async function runAsWorker() {
|
||||
|
||||
const results: TaskResult[] = []
|
||||
const taskPromises: Promise<void>[] = []
|
||||
const batchProcessorOptions = {
|
||||
isForceMode: process.env.FORCE_MODE === 'true',
|
||||
isForceManifest: process.env.FORCE_MANIFEST === 'true',
|
||||
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
|
||||
}
|
||||
const batchBuilderOptions: BuilderOptions = {
|
||||
isForceMode: batchProcessorOptions.isForceMode,
|
||||
isForceManifest: batchProcessorOptions.isForceManifest,
|
||||
isForceThumbnails: batchProcessorOptions.isForceThumbnails,
|
||||
concurrencyLimit: undefined,
|
||||
}
|
||||
|
||||
// 创建所有任务的并发执行 Promise
|
||||
for (const task of message.tasks) {
|
||||
@@ -185,12 +212,12 @@ export async function runAsWorker() {
|
||||
imageObjects.length,
|
||||
existingManifestMap,
|
||||
legacyLivePhotoMap,
|
||||
{
|
||||
isForceMode: process.env.FORCE_MODE === 'true',
|
||||
isForceManifest: process.env.FORCE_MANIFEST === 'true',
|
||||
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
|
||||
},
|
||||
batchProcessorOptions,
|
||||
builder,
|
||||
{
|
||||
runState: pluginRunState,
|
||||
builderOptions: batchBuilderOptions,
|
||||
},
|
||||
)
|
||||
|
||||
// 添加成功结果
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import type { StorageConfig, StorageProvider } from './interfaces'
|
||||
import { EagleStorageProvider } from './providers/eagle-provider.js'
|
||||
import { GitHubStorageProvider } from './providers/github-provider.js'
|
||||
import { LocalStorageProvider } from './providers/local-provider.js'
|
||||
import { S3StorageProvider } from './providers/s3-provider.js'
|
||||
import type { StorageConfig, StorageProvider } from './interfaces.js'
|
||||
|
||||
export type StorageProviderFactory<T extends StorageConfig = StorageConfig> = (
|
||||
config: T,
|
||||
) => StorageProvider
|
||||
|
||||
export class StorageFactory {
|
||||
private static providers = new Map<string, StorageProviderFactory>()
|
||||
|
||||
/**
|
||||
* Register or override a storage provider factory.
|
||||
*/
|
||||
static registerProvider(
|
||||
provider: string,
|
||||
factory: StorageProviderFactory,
|
||||
): void {
|
||||
StorageFactory.providers.set(provider, factory)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置创建存储提供商实例
|
||||
* @param config 存储配置
|
||||
* @returns 存储提供商实例
|
||||
*/
|
||||
static createProvider(config: StorageConfig): StorageProvider {
|
||||
switch (config.provider) {
|
||||
case 's3': {
|
||||
return new S3StorageProvider(config)
|
||||
}
|
||||
case 'github': {
|
||||
return new GitHubStorageProvider(config)
|
||||
}
|
||||
case 'eagle': {
|
||||
return new EagleStorageProvider(config)
|
||||
}
|
||||
case 'local': {
|
||||
return new LocalStorageProvider(config)
|
||||
}
|
||||
const factory = StorageFactory.providers.get(config.provider)
|
||||
|
||||
if (!factory) {
|
||||
throw new Error(
|
||||
`Unsupported storage provider: ${config.provider as string}`,
|
||||
)
|
||||
}
|
||||
|
||||
return factory(config)
|
||||
}
|
||||
|
||||
static getRegisteredProviders(): string[] {
|
||||
return Array.from(StorageFactory.providers.keys())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
} from './interfaces.js'
|
||||
|
||||
// 导出工厂类
|
||||
export type { StorageProviderFactory } from './factory.js'
|
||||
export { StorageFactory } from './factory.js'
|
||||
|
||||
// 导出管理器
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BuilderPluginConfigEntry } from '../plugins/types.js'
|
||||
import type { StorageConfig } from '../storage/interfaces.js'
|
||||
|
||||
export interface BuilderConfig {
|
||||
@@ -29,4 +30,13 @@ export interface BuilderConfig {
|
||||
workerCount: number
|
||||
}
|
||||
}
|
||||
plugins?: BuilderPluginConfigEntry[]
|
||||
}
|
||||
|
||||
type DeepPartial<T> = T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T
|
||||
|
||||
export type BuilderConfigInput = DeepPartial<Omit<BuilderConfig, 'plugins'>> & {
|
||||
plugins?: BuilderPluginConfigEntry[]
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface PhotoManifestItem extends PhotoInfo {
|
||||
export interface ProcessPhotoResult {
|
||||
item: PhotoManifestItem | null
|
||||
type: 'processed' | 'skipped' | 'new' | 'failed'
|
||||
pluginData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PickedExif {
|
||||
|
||||
15
packages/builder/src/utils/clone.ts
Normal file
15
packages/builder/src/utils/clone.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { deserialize as v8Deserialize, serialize as v8Serialize } from 'node:v8'
|
||||
|
||||
export function clone<T>(value: T): T {
|
||||
const maybeStructuredClone = (
|
||||
globalThis as typeof globalThis & {
|
||||
structuredClone?: <U>(input: U) => U
|
||||
}
|
||||
).structuredClone
|
||||
|
||||
if (typeof maybeStructuredClone === 'function') {
|
||||
return maybeStructuredClone(value)
|
||||
}
|
||||
|
||||
return v8Deserialize(v8Serialize(value))
|
||||
}
|
||||
@@ -27,9 +27,6 @@
|
||||
"@pkg": [
|
||||
"./package.json"
|
||||
],
|
||||
"@builder": [
|
||||
"../../builder.config.ts"
|
||||
],
|
||||
"@env": [
|
||||
"../../env.ts"
|
||||
]
|
||||
|
||||
9
packages/builder/tsdown.config.ts
Normal file
9
packages/builder/tsdown.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: './src/index.ts',
|
||||
outDir: './dist',
|
||||
format: 'esm',
|
||||
clean: true,
|
||||
dts: true,
|
||||
})
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: Overview
|
||||
createdAt: 2025-07-20T22:35:03+08:00
|
||||
lastModified: 2025-07-20T22:35:03+08:00
|
||||
lastModified: 2025-10-28T19:48:05+08:00
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true" alt="Afilmory" width="100%" />
|
||||
<img
|
||||
src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true"
|
||||
alt="Afilmory"
|
||||
width="100%"
|
||||
/>
|
||||
</p>
|
||||
|
||||
Afilmory (/əˈfɪlməri/, "uh-FIL-muh-ree") is a term created for personal photography websites, blending Auto Focus (AF), aperture (light control), film (vintage medium), and memory (captured moments).
|
||||
@@ -169,7 +173,7 @@ pnpm build
|
||||
### Notes
|
||||
|
||||
- Ensure your S3 bucket already contains photo files
|
||||
- If using remote repository, configure `builder.config.json` first
|
||||
- If using remote repository, configure `builder.config.ts` first
|
||||
|
||||
## 🔧 Advanced Usage
|
||||
|
||||
@@ -214,4 +218,4 @@ MIT License © 2025 Innei
|
||||
- [Personal Website](https://innei.in)
|
||||
- [GitHub](https://github.com/innei)
|
||||
|
||||
If this project helps you, please give it a ⭐️ Star for support!
|
||||
If this project helps you, please give it a ⭐️ Star for support!
|
||||
|
||||
@@ -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
|
||||
```
|
||||
```
|
||||
|
||||
231
pnpm-lock.yaml
generated
231
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user