mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: switch to plugin
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Overview
|
||||
createdAt: 2025-07-20T22:35:03+08:00
|
||||
lastModified: 2025-11-20T23:41:32+08:00
|
||||
lastModified: 2025-11-21T21:25:29+08:00
|
||||
---
|
||||
|
||||
# Overview
|
||||
@@ -128,12 +128,13 @@ This will automatically pull resources from the remote repository, avoiding rebu
|
||||
- `digestSuffixLength`: Digest suffix length for deterministic IDs
|
||||
- `supportedFormats`: Optional allowlist of file extensions to process
|
||||
|
||||
#### Geocoding (`user.geocoding`)
|
||||
#### Geocoding (via `geocodingPlugin`)
|
||||
|
||||
- `enableGeocoding`: Enable reverse geocoding from GPS coordinates (default: `false`)
|
||||
- `geocodingProvider`: Geocoding service provider (`'mapbox'` | `'nominatim'` | `'auto'`, default: `'auto'`)
|
||||
- `enable`: Enable reverse geocoding from GPS coordinates (default: `false`)
|
||||
- `provider`: Geocoding service provider (`'mapbox'` | `'nominatim'` | `'auto'`, default: `'auto'`)
|
||||
- `mapboxToken`: Mapbox access token (required when using Mapbox provider)
|
||||
- `nominatimBaseUrl`: Custom Nominatim base URL (optional, defaults to OpenStreetMap's public instance)
|
||||
- `cachePrecision`: Coordinate cache precision (decimals, default: `4`)
|
||||
|
||||
#### System Observability (`system.observability`)
|
||||
|
||||
@@ -148,34 +149,40 @@ This will automatically pull resources from the remote repository, avoiding rebu
|
||||
|
||||
#### Geocoding Configuration Example
|
||||
|
||||
To enable reverse geocoding, configure the geocoding provider under `user.geocoding` in your `builder.config.ts`:
|
||||
Enable reverse geocoding by adding the geocoding plugin in `builder.config.ts`:
|
||||
|
||||
**Using Nominatim (Free, but rate-limited):**
|
||||
|
||||
```typescript
|
||||
{
|
||||
user: {
|
||||
geocoding: {
|
||||
enableGeocoding: true,
|
||||
import { defineBuilderConfig, geocodingPlugin } from '@afilmory/builder'
|
||||
|
||||
export default defineBuilderConfig(() => ({
|
||||
plugins: [
|
||||
geocodingPlugin({
|
||||
enable: true,
|
||||
provider: 'nominatim',
|
||||
// Optional: use custom Nominatim instance
|
||||
// nominatimBaseUrl: 'https://your-nominatim-instance.com'
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
],
|
||||
}))
|
||||
|
||||
```
|
||||
|
||||
**Using Mapbox:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
user: {
|
||||
geocoding: {
|
||||
enableGeocoding: true,
|
||||
import { defineBuilderConfig, geocodingPlugin } from '@afilmory/builder'
|
||||
|
||||
export default defineBuilderConfig(() => ({
|
||||
plugins: [
|
||||
geocodingPlugin({
|
||||
enable: true,
|
||||
provider: 'mapbox',
|
||||
mapboxToken: process.env.MAPBOX_TOKEN,
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
],
|
||||
}))
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
@@ -183,6 +190,7 @@ To enable reverse geocoding, configure the geocoding provider under `user.geocod
|
||||
- Nominatim is free but has strict rate limits (1 request/second)
|
||||
- Both providers support intelligent caching to minimize API calls
|
||||
- Location data includes country, city, and location name extracted from GPS coordinates
|
||||
- Reverse geocoding is handled by the built-in geocoding plugin and runs only when the plugin is added to `plugins` with `enable: true`
|
||||
|
||||
## 📋 CLI Commands
|
||||
|
||||
|
||||
@@ -55,12 +55,6 @@ export default defineBuilderConfig(() => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
geocoding: {
|
||||
enableGeocoding: false,
|
||||
geocodingProvider: 'auto',
|
||||
},
|
||||
},
|
||||
// plugins: [thumbnailStoragePlugin()],
|
||||
plugins: [
|
||||
githubRepoSyncPlugin({
|
||||
|
||||
@@ -4,9 +4,6 @@ import { thumbnailExists } from '../image/thumbnail.js'
|
||||
import { logger } from '../logger/index.js'
|
||||
import { handleDeletedPhotos, loadExistingManifest, needsUpdate, saveManifest } from '../manifest/manager.js'
|
||||
import { CURRENT_MANIFEST_VERSION } from '../manifest/version.js'
|
||||
import { createStorageKeyNormalizer, runWithPhotoExecutionContext } from '../photo/execution-context.js'
|
||||
import { createGeocodingProvider, parseGPSCoordinates } from '../photo/geocoding.js'
|
||||
import { createPhotoProcessingLoggers } from '../photo/logger-adapter.js'
|
||||
import type { PhotoProcessorOptions } from '../photo/processor.js'
|
||||
import { processPhoto } from '../photo/processor.js'
|
||||
import type { PluginRunState } from '../plugins/manager.js'
|
||||
@@ -21,7 +18,7 @@ import type { StorageConfig } from '../storage/index.js'
|
||||
import { StorageFactory, StorageManager } from '../storage/index.js'
|
||||
import type { BuilderConfig, UserBuilderSettings } from '../types/config.js'
|
||||
import type { AfilmoryManifest, CameraInfo, LensInfo } from '../types/manifest.js'
|
||||
import type { LocationInfo, PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import { ClusterPool } from '../worker/cluster-pool.js'
|
||||
import type { TaskCompletedPayload } from '../worker/pool.js'
|
||||
import { WorkerPool } from '../worker/pool.js'
|
||||
@@ -420,13 +417,6 @@ export class AfilmoryBuilder {
|
||||
})
|
||||
}
|
||||
|
||||
const locationRetryStats = await this.retryMissingLocations(manifest)
|
||||
if (locationRetryStats.attempted > 0) {
|
||||
logger.main.info(
|
||||
`📍 为 ${locationRetryStats.attempted} 张缺失位置信息的照片尝试补全,成功 ${locationRetryStats.updated} 张`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.emitPluginEvent(runState, 'afterProcessTasks', {
|
||||
options,
|
||||
tasks: tasksToProcess,
|
||||
@@ -688,78 +678,6 @@ export class AfilmoryBuilder {
|
||||
return this.storageManager
|
||||
}
|
||||
|
||||
private async retryMissingLocations(manifest: PhotoManifestItem[]): Promise<{ attempted: number; updated: number }> {
|
||||
const geocodingSettings = this.getUserSettings().geocoding
|
||||
if (!geocodingSettings.enableGeocoding) {
|
||||
return { attempted: 0, updated: 0 }
|
||||
}
|
||||
|
||||
const provider = createGeocodingProvider(
|
||||
geocodingSettings.geocodingProvider || 'auto',
|
||||
geocodingSettings.mapboxToken,
|
||||
geocodingSettings.nominatimBaseUrl,
|
||||
)
|
||||
|
||||
if (!provider) {
|
||||
return { attempted: 0, updated: 0 }
|
||||
}
|
||||
|
||||
const hasCandidate = manifest.some(
|
||||
(item) =>
|
||||
!item.location && item.exif && item.exif.GPSLatitude !== undefined && item.exif.GPSLongitude !== undefined,
|
||||
)
|
||||
|
||||
if (!hasCandidate) {
|
||||
return { attempted: 0, updated: 0 }
|
||||
}
|
||||
|
||||
const storageManager = this.ensureStorageManager()
|
||||
const storageConfig = this.getStorageConfig()
|
||||
const normalizeStorageKey = createStorageKeyNormalizer(storageConfig)
|
||||
const loggers = createPhotoProcessingLoggers(0, logger)
|
||||
|
||||
return await runWithPhotoExecutionContext(
|
||||
{
|
||||
builder: this,
|
||||
storageManager,
|
||||
storageConfig,
|
||||
normalizeStorageKey,
|
||||
loggers,
|
||||
},
|
||||
async () => {
|
||||
const coordinateCache = new Map<string, LocationInfo | null>()
|
||||
let attempted = 0
|
||||
let updated = 0
|
||||
|
||||
for (const item of manifest) {
|
||||
if (item.location || !item.exif) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { latitude, longitude } = parseGPSCoordinates(item.exif)
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cacheKey = `${latitude.toFixed(4)},${longitude.toFixed(4)}`
|
||||
let locationInfo = coordinateCache.get(cacheKey)
|
||||
if (locationInfo === undefined) {
|
||||
locationInfo = await provider.reverseGeocode(latitude, longitude)
|
||||
coordinateCache.set(cacheKey, locationInfo ?? null)
|
||||
}
|
||||
|
||||
attempted++
|
||||
if (locationInfo) {
|
||||
item.location = locationInfo
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted, updated }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private getUserSettings(): UserBuilderSettings {
|
||||
if (!this.config.user) {
|
||||
throw new Error('User configuration is missing. 请配置 system/user 设置。')
|
||||
|
||||
@@ -28,14 +28,7 @@ export function createDefaultBuilderConfig(): BuilderConfig {
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
storage: null,
|
||||
geocoding: {
|
||||
// 地理编码默认配置
|
||||
enableGeocoding: false, // 默认关闭,需要用户主动启用
|
||||
geocodingProvider: 'auto',
|
||||
},
|
||||
},
|
||||
user: null!,
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,21 +57,8 @@ function ensureUserSettings(target: BuilderConfig): UserBuilderSettings {
|
||||
if (!target.user) {
|
||||
target.user = {
|
||||
storage: null,
|
||||
geocoding: {
|
||||
enableGeocoding: false,
|
||||
geocodingProvider: 'auto',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧的配置对象缺少 geocoding 字段的情况
|
||||
if (!target.user.geocoding) {
|
||||
target.user.geocoding = {
|
||||
enableGeocoding: false,
|
||||
geocodingProvider: 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
return target.user
|
||||
}
|
||||
|
||||
@@ -82,13 +69,6 @@ function applyUserOverrides(target: BuilderConfig, overrides?: BuilderConfigInpu
|
||||
if (overrides.storage !== undefined) {
|
||||
user.storage = overrides.storage as StorageConfig | null
|
||||
}
|
||||
|
||||
if (overrides.geocoding) {
|
||||
user.geocoding = {
|
||||
...user.geocoding,
|
||||
...overrides.geocoding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBuilderConfig(defaults: BuilderConfig, input: BuilderConfigInput): BuilderConfig {
|
||||
|
||||
@@ -13,6 +13,8 @@ export {
|
||||
processPhotoWithPipeline,
|
||||
} from './photo/image-pipeline.js'
|
||||
export type { PhotoProcessorOptions } from './photo/processor.js'
|
||||
export type { GeocodingPluginOptions } from './plugins/geocoding.js'
|
||||
export { default as geocodingPlugin } from './plugins/geocoding.js'
|
||||
export type { GitHubRepoSyncPluginOptions } from './plugins/github-repo-sync.js'
|
||||
export { createGitHubRepoSyncPlugin, default as githubRepoSyncPlugin } from './plugins/github-repo-sync.js'
|
||||
export type { B2StoragePluginOptions } from './plugins/storage/b2.js'
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- **`live-photo-handler.ts`** - Live Photo 检测和处理
|
||||
- **`logger-adapter.ts`** - Logger 适配器,实现适配器模式
|
||||
- **`info-extractor.ts`** - 照片信息提取
|
||||
- **`geocoding.ts`** - 反向地理编码,从 GPS 坐标提取位置信息
|
||||
- **`geocoding.ts`** - 反向地理编码提供者定义(通过 geocoding 插件调用)
|
||||
|
||||
### 设计模式
|
||||
|
||||
@@ -43,11 +43,10 @@ class CompatibleLoggerAdapter implements PhotoLogger {
|
||||
2. 创建 Sharp 实例
|
||||
3. 处理缩略图和 blurhash
|
||||
4. 处理 EXIF 数据
|
||||
5. 处理影调分析
|
||||
6. 处理地理编码(从 GPS 坐标反向解析位置信息)
|
||||
5. HDR / Motion Photo / Live Photo 检测
|
||||
6. 处理影调分析
|
||||
7. 提取照片信息
|
||||
8. 处理 Live Photo
|
||||
9. 构建照片清单项
|
||||
8. 构建照片清单项
|
||||
|
||||
### 主要改进
|
||||
|
||||
@@ -55,7 +54,7 @@ class CompatibleLoggerAdapter implements PhotoLogger {
|
||||
2. **Logger 适配器**: 使用异步执行上下文管理 logger,避免全局状态污染
|
||||
3. **缓存管理**: 统一管理各种数据的缓存和复用逻辑
|
||||
4. **Live Photo 处理**: 专门的模块处理 Live Photo 检测和匹配
|
||||
5. **反向地理编码**: 支持从 GPS 坐标提取位置信息,支持多个地理编码提供商
|
||||
5. **反向地理编码插件**: 通过 geocoding 插件在构建生命周期中写入位置信息,支持多个地理编码提供商
|
||||
6. **类型安全**: 完善的 TypeScript 类型定义
|
||||
|
||||
### 使用方法
|
||||
@@ -83,8 +82,7 @@ const result = await processPhoto(
|
||||
#### 单独使用各个模块
|
||||
|
||||
```typescript
|
||||
import { processLivePhoto, processThumbnailAndBlurhash, processExifData, processLocationData } from './index.js'
|
||||
import { createGeocodingProvider } from './geocoding.js'
|
||||
import { processLivePhoto, processThumbnailAndBlurhash, processExifData } from './index.js'
|
||||
|
||||
// Live Photo 处理
|
||||
const livePhotoResult = processLivePhoto(photoKey, livePhotoMap, builder.getStorageManager())
|
||||
@@ -94,9 +92,25 @@ const thumbnailResult = await processThumbnailAndBlurhash(imageBuffer, photoId,
|
||||
|
||||
// EXIF 处理
|
||||
const exifData = await processExifData(imageBuffer, rawImageBuffer, photoKey, existingItem, options)
|
||||
```
|
||||
|
||||
// 地理编码处理
|
||||
const locationInfo = await processLocationData(exifData, photoKey, existingItem, options)
|
||||
#### 启用反向地理编码
|
||||
|
||||
在 `builder.config.ts` 中通过插件开启:
|
||||
|
||||
```typescript
|
||||
import { defineBuilderConfig, geocodingPlugin } from '@afilmory/builder'
|
||||
|
||||
export default defineBuilderConfig(() => ({
|
||||
plugins: [
|
||||
geocodingPlugin({
|
||||
enable: true,
|
||||
provider: 'auto',
|
||||
mapboxToken: process.env.MAPBOX_TOKEN,
|
||||
// nominatimBaseUrl: 'https://your-nominatim-instance.com',
|
||||
}),
|
||||
],
|
||||
}))
|
||||
```
|
||||
|
||||
### 扩展性
|
||||
|
||||
@@ -9,10 +9,7 @@ import { extractExifData } from '../image/exif.js'
|
||||
import { calculateHistogramAndAnalyzeTone } from '../image/histogram.js'
|
||||
import { generateThumbnailAndBlurhash, thumbnailExists } from '../image/thumbnail.js'
|
||||
import { workdir } from '../path.js'
|
||||
import type { LocationInfo, PhotoManifestItem, PickedExif, ToneAnalysis } from '../types/photo.js'
|
||||
import { getPhotoExecutionContext } from './execution-context.js'
|
||||
import type { GeocodingProvider } from './geocoding.js'
|
||||
import { createGeocodingProvider, extractLocationFromGPS, parseGPSCoordinates } from './geocoding.js'
|
||||
import type { PhotoManifestItem, PickedExif, ToneAnalysis } from '../types/photo.js'
|
||||
import { getGlobalLoggers } from './logger-adapter.js'
|
||||
import type { PhotoProcessorOptions } from './processor.js'
|
||||
|
||||
@@ -123,100 +120,3 @@ export async function processToneAnalysis(
|
||||
// 计算新的影调分析
|
||||
return await calculateHistogramAndAnalyzeTone(sharpInstance)
|
||||
}
|
||||
|
||||
// ============ 地理编码相关 ============
|
||||
|
||||
// 坐标缓存(避免重复 API 调用)
|
||||
// Key 格式:"{lat},{lon}" 精确到小数点后4位(约10米精度)
|
||||
const locationCache = new Map<string, LocationInfo | null>()
|
||||
|
||||
// 单例提供者(避免重复创建)
|
||||
let cachedProvider: GeocodingProvider | null = null
|
||||
let lastProviderConfig: string | null = null
|
||||
|
||||
/**
|
||||
* 处理位置数据(反向地理编码)
|
||||
* 优先复用现有数据,如果不存在或需要强制更新则进行地理编码
|
||||
*/
|
||||
export async function processLocationData(
|
||||
exifData: PickedExif | null,
|
||||
photoKey: string,
|
||||
existingItem: PhotoManifestItem | undefined,
|
||||
options: PhotoProcessorOptions,
|
||||
): Promise<LocationInfo | null> {
|
||||
const loggers = getGlobalLoggers()
|
||||
|
||||
try {
|
||||
// 获取配置
|
||||
const context = getPhotoExecutionContext()
|
||||
const config = context.builder.getConfig()
|
||||
const geocodingSettings = config.user?.geocoding ?? {
|
||||
enableGeocoding: false,
|
||||
geocodingProvider: 'auto',
|
||||
}
|
||||
|
||||
// 检查是否启用地理编码
|
||||
if (!geocodingSettings.enableGeocoding) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查是否可以复用现有数据
|
||||
if (!options.isForceMode && !options.isForceManifest && existingItem?.location) {
|
||||
const photoId = path.basename(photoKey, path.extname(photoKey))
|
||||
loggers.location.info(`复用现有位置数据:${photoId}`)
|
||||
return existingItem.location
|
||||
}
|
||||
|
||||
// 检查 EXIF 是否包含 GPS 数据
|
||||
if (!exifData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析 GPS 坐标
|
||||
const { latitude, longitude } = parseGPSCoordinates(exifData)
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 生成缓存 key(精确到小数点后4位)
|
||||
const cacheKey = `${latitude.toFixed(4)},${longitude.toFixed(4)}`
|
||||
|
||||
// 检查缓存
|
||||
if (locationCache.has(cacheKey)) {
|
||||
const cached = locationCache.get(cacheKey)
|
||||
const photoId = path.basename(photoKey, path.extname(photoKey))
|
||||
loggers.location.info(`使用缓存的位置数据:${photoId} (${cacheKey})`)
|
||||
return cached ?? null
|
||||
}
|
||||
|
||||
// 创建或复用地理编码提供者
|
||||
const providerType = geocodingSettings.geocodingProvider || 'auto'
|
||||
const providerConfigKey = `${providerType}:${geocodingSettings.mapboxToken || ''}:${geocodingSettings.nominatimBaseUrl || ''}`
|
||||
|
||||
if (!cachedProvider || lastProviderConfig !== providerConfigKey) {
|
||||
cachedProvider = createGeocodingProvider(
|
||||
providerType,
|
||||
geocodingSettings.mapboxToken,
|
||||
geocodingSettings.nominatimBaseUrl,
|
||||
)
|
||||
lastProviderConfig = providerConfigKey
|
||||
}
|
||||
|
||||
if (!cachedProvider) {
|
||||
loggers.location.warn('无法创建地理编码提供者')
|
||||
return null
|
||||
}
|
||||
|
||||
// 调用反向地理编码 API
|
||||
const locationInfo = await extractLocationFromGPS(latitude, longitude, cachedProvider)
|
||||
|
||||
// 缓存结果(包括 null)
|
||||
locationCache.set(cacheKey, locationInfo)
|
||||
|
||||
return locationInfo
|
||||
} catch (error) {
|
||||
loggers.location.error('处理位置数据失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,7 @@ import { THUMBNAIL_PLUGIN_DATA_KEY } from '../plugins/thumbnail-storage/shared.j
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import type { S3ObjectLike } from '../types/s3.js'
|
||||
import { shouldProcessPhoto } from './cache-manager.js'
|
||||
import {
|
||||
processExifData,
|
||||
processLocationData,
|
||||
processThumbnailAndBlurhash,
|
||||
processToneAnalysis,
|
||||
} from './data-processors.js'
|
||||
import { processExifData, processThumbnailAndBlurhash, processToneAnalysis } from './data-processors.js'
|
||||
import { getPhotoExecutionContext } from './execution-context.js'
|
||||
import { detectGainMap } from './gainmap-detector.js'
|
||||
import { extractPhotoInfo } from './info-extractor.js'
|
||||
@@ -206,12 +201,9 @@ export async function executePhotoProcessingPipeline(
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
|
||||
// 7. 处理影调分析
|
||||
// 8. 处理影调分析
|
||||
const toneAnalysis = await processToneAnalysis(sharpInstance, photoKey, existingItem, options)
|
||||
|
||||
// 8. 处理位置数据(反向地理编码)
|
||||
const location = await processLocationData(exifData, photoKey, existingItem, options)
|
||||
|
||||
// 9. 提取照片信息
|
||||
const photoInfo = extractPhotoInfo(photoKey, exifData)
|
||||
|
||||
@@ -235,7 +227,7 @@ export async function executePhotoProcessingPipeline(
|
||||
digest: contentDigest,
|
||||
exif: exifData,
|
||||
toneAnalysis,
|
||||
location,
|
||||
location: existingItem?.location ?? null,
|
||||
// Video source (Motion Photo or Live Photo)
|
||||
video:
|
||||
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
|
||||
|
||||
311
packages/builder/src/plugins/geocoding.ts
Normal file
311
packages/builder/src/plugins/geocoding.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { AfilmoryBuilder } from '../builder/builder.js'
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import {
|
||||
createStorageKeyNormalizer,
|
||||
getPhotoExecutionContext,
|
||||
runWithPhotoExecutionContext,
|
||||
} from '../photo/execution-context.js'
|
||||
import type { GeocodingProvider } from '../photo/geocoding.js'
|
||||
import { createGeocodingProvider, extractLocationFromGPS, parseGPSCoordinates } from '../photo/geocoding.js'
|
||||
import { createPhotoProcessingLoggers } from '../photo/logger-adapter.js'
|
||||
import type { LocationInfo, PhotoManifestItem, PickedExif } from '../types/photo.js'
|
||||
import type { BuilderPlugin } from './types.js'
|
||||
|
||||
const PLUGIN_NAME = 'afilmory:geocoding'
|
||||
const RUN_STATE_KEY = 'geocodingState'
|
||||
const DEFAULT_CACHE_PRECISION = 4
|
||||
|
||||
interface GeocodingPluginOptions {
|
||||
enable?: boolean
|
||||
provider?: 'mapbox' | 'nominatim' | 'auto'
|
||||
mapboxToken?: string
|
||||
nominatimBaseUrl?: string
|
||||
cachePrecision?: number
|
||||
}
|
||||
type GeocodingPluginOptionsResolved = Required<Pick<GeocodingPluginOptions, 'enable' | 'provider'>> &
|
||||
Pick<GeocodingPluginOptions, 'mapboxToken' | 'nominatimBaseUrl' | 'cachePrecision'> & {
|
||||
language: string | null
|
||||
}
|
||||
|
||||
interface ResolvedGeocodingSettings {
|
||||
provider: 'mapbox' | 'nominatim' | 'auto'
|
||||
mapboxToken?: string
|
||||
nominatimBaseUrl?: string
|
||||
cachePrecision: number
|
||||
}
|
||||
|
||||
interface GeocodingState {
|
||||
provider: GeocodingProvider | null
|
||||
providerKey: string | null
|
||||
cache: Map<string, LocationInfo | null>
|
||||
}
|
||||
|
||||
interface LocationResolutionResult {
|
||||
attempted: boolean
|
||||
updated: boolean
|
||||
}
|
||||
|
||||
type LocationLogger = Logger['main']
|
||||
|
||||
function normalizeCachePrecision(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return DEFAULT_CACHE_PRECISION
|
||||
}
|
||||
|
||||
const rounded = Math.round(value)
|
||||
return Math.max(0, Math.min(10, rounded))
|
||||
}
|
||||
|
||||
function normalizeLanguage(value: string | string[] | undefined): string | null {
|
||||
if (!value) return null
|
||||
const parts = Array.isArray(value) ? value : String(value).split(',')
|
||||
const normalized = parts.map((v) => v.trim()).filter(Boolean)
|
||||
return normalized.length > 0 ? normalized.join(',') : null
|
||||
}
|
||||
|
||||
function resolveSettings(options: GeocodingPluginOptions): GeocodingPluginOptionsResolved {
|
||||
return {
|
||||
enable: options.enable ?? false,
|
||||
provider: options.provider ?? 'auto',
|
||||
mapboxToken: options.mapboxToken,
|
||||
nominatimBaseUrl: options.nominatimBaseUrl,
|
||||
cachePrecision: normalizeCachePrecision(options.cachePrecision ?? DEFAULT_CACHE_PRECISION),
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateState(runShared: Map<string, unknown>): GeocodingState {
|
||||
const existing = runShared.get(RUN_STATE_KEY) as GeocodingState | undefined
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const next: GeocodingState = {
|
||||
provider: null,
|
||||
providerKey: null,
|
||||
cache: new Map(),
|
||||
}
|
||||
runShared.set(RUN_STATE_KEY, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function buildProviderKey(settings: ResolvedGeocodingSettings): string {
|
||||
return `${settings.provider}:${settings.mapboxToken ?? ''}:${settings.nominatimBaseUrl ?? ''}`
|
||||
}
|
||||
|
||||
function ensureProvider(
|
||||
state: GeocodingState,
|
||||
settings: ResolvedGeocodingSettings,
|
||||
logger: LocationLogger,
|
||||
): GeocodingProvider | null {
|
||||
const providerKey = buildProviderKey(settings)
|
||||
if (state.provider && state.providerKey === providerKey) {
|
||||
return state.provider
|
||||
}
|
||||
|
||||
const provider = createGeocodingProvider(settings.provider, settings.mapboxToken, settings.nominatimBaseUrl)
|
||||
|
||||
if (!provider) {
|
||||
logger.warn('无法创建地理编码提供者,请检查 geocoding 配置和 Token')
|
||||
state.provider = null
|
||||
state.providerKey = null
|
||||
return null
|
||||
}
|
||||
|
||||
state.provider = provider
|
||||
state.providerKey = providerKey
|
||||
return provider
|
||||
}
|
||||
|
||||
async function ensurePhotoContext<T>(builder: AfilmoryBuilder, logger: Logger, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
getPhotoExecutionContext()
|
||||
return await fn()
|
||||
} catch {
|
||||
const storageConfig = builder.getStorageConfig()
|
||||
const storageManager = builder.getStorageManager()
|
||||
const normalizeStorageKey = createStorageKeyNormalizer(storageConfig)
|
||||
const loggers = createPhotoProcessingLoggers(0, logger)
|
||||
|
||||
return await runWithPhotoExecutionContext(
|
||||
{
|
||||
builder,
|
||||
storageManager,
|
||||
storageConfig,
|
||||
normalizeStorageKey,
|
||||
loggers,
|
||||
},
|
||||
fn,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildCacheKey(latitude: number, longitude: number, precision: number): string {
|
||||
return `${latitude.toFixed(precision)},${longitude.toFixed(precision)}`
|
||||
}
|
||||
|
||||
async function resolveLocationForItem(
|
||||
item: PhotoManifestItem,
|
||||
exif: PickedExif | null | undefined,
|
||||
state: GeocodingState,
|
||||
settings: ResolvedGeocodingSettings,
|
||||
provider: GeocodingProvider,
|
||||
shouldOverwriteExisting: boolean,
|
||||
): Promise<LocationResolutionResult> {
|
||||
if (item.location && !shouldOverwriteExisting) {
|
||||
return { attempted: false, updated: false }
|
||||
}
|
||||
|
||||
if (!exif) {
|
||||
if (shouldOverwriteExisting) {
|
||||
item.location = null
|
||||
}
|
||||
return { attempted: false, updated: false }
|
||||
}
|
||||
|
||||
const { latitude, longitude } = parseGPSCoordinates(exif)
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
if (shouldOverwriteExisting) {
|
||||
item.location = null
|
||||
}
|
||||
return { attempted: false, updated: false }
|
||||
}
|
||||
|
||||
const cacheKey = buildCacheKey(latitude, longitude, settings.cachePrecision)
|
||||
const cached = state.cache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
item.location = cached
|
||||
return { attempted: true, updated: true }
|
||||
}
|
||||
if (shouldOverwriteExisting) {
|
||||
item.location = null
|
||||
}
|
||||
return { attempted: true, updated: false }
|
||||
}
|
||||
|
||||
const location = await extractLocationFromGPS(latitude, longitude, provider)
|
||||
state.cache.set(cacheKey, location ?? null)
|
||||
|
||||
if (location) {
|
||||
item.location = location
|
||||
return { attempted: true, updated: true }
|
||||
}
|
||||
|
||||
if (shouldOverwriteExisting) {
|
||||
item.location = null
|
||||
}
|
||||
|
||||
return { attempted: true, updated: false }
|
||||
}
|
||||
|
||||
export default function geocodingPlugin(options: GeocodingPluginOptions = {}): BuilderPlugin {
|
||||
const normalizedOptions = resolveSettings(options)
|
||||
let settings: ResolvedGeocodingSettings | null = null
|
||||
|
||||
return {
|
||||
name: PLUGIN_NAME,
|
||||
hooks: {
|
||||
onInit: () => {
|
||||
settings = {
|
||||
provider: normalizedOptions.provider,
|
||||
mapboxToken: normalizedOptions.mapboxToken,
|
||||
nominatimBaseUrl: normalizedOptions.nominatimBaseUrl,
|
||||
cachePrecision: normalizedOptions.cachePrecision ?? DEFAULT_CACHE_PRECISION,
|
||||
language: normalizedOptions.language,
|
||||
}
|
||||
},
|
||||
afterPhotoProcess: async ({ builder, payload, runShared, logger }) => {
|
||||
if (!settings) return
|
||||
|
||||
const { item } = payload.result
|
||||
if (!item) return
|
||||
|
||||
const shouldOverwriteExisting = payload.options.isForceMode || payload.options.isForceManifest
|
||||
|
||||
if (!normalizedOptions.enable) return
|
||||
|
||||
// 当已有位置信息且未强制刷新时,不重复调用地理编码
|
||||
if (item.location && !shouldOverwriteExisting) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentSettings = settings
|
||||
|
||||
await ensurePhotoContext(builder, logger, async () => {
|
||||
const state = getOrCreateState(runShared)
|
||||
const locationLogger = logger.main.withTag('LOCATION')
|
||||
const provider = ensureProvider(state, currentSettings, locationLogger)
|
||||
if (!provider) {
|
||||
if (shouldOverwriteExisting) {
|
||||
item.location = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const exif = item.exif ?? payload.context.existingItem?.exif ?? null
|
||||
await resolveLocationForItem(item, exif, state, currentSettings, provider, shouldOverwriteExisting)
|
||||
})
|
||||
},
|
||||
afterProcessTasks: async ({ builder, payload, runShared, logger }) => {
|
||||
if (!settings || !normalizedOptions.enable) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentSettings = settings
|
||||
const state = getOrCreateState(runShared)
|
||||
const locationLogger = logger.main.withTag('LOCATION')
|
||||
const provider = ensureProvider(state, currentSettings, locationLogger)
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
const storageConfig = builder.getStorageConfig()
|
||||
const storageManager = builder.getStorageManager()
|
||||
const normalizeStorageKey = createStorageKeyNormalizer(storageConfig)
|
||||
const loggers = createPhotoProcessingLoggers(0, logger)
|
||||
|
||||
await runWithPhotoExecutionContext(
|
||||
{
|
||||
builder,
|
||||
storageManager,
|
||||
storageConfig,
|
||||
normalizeStorageKey,
|
||||
loggers,
|
||||
},
|
||||
async () => {
|
||||
let attempted = 0
|
||||
let updated = 0
|
||||
const shouldOverwriteExisting = payload.options.isForceMode || payload.options.isForceManifest
|
||||
|
||||
for (const item of payload.manifest) {
|
||||
if (!item) continue
|
||||
if (item.location) continue
|
||||
|
||||
const { attempted: didAttempt, updated: didUpdate } = await resolveLocationForItem(
|
||||
item,
|
||||
item.exif,
|
||||
state,
|
||||
currentSettings,
|
||||
provider,
|
||||
shouldOverwriteExisting,
|
||||
)
|
||||
|
||||
if (didAttempt) {
|
||||
attempted++
|
||||
if (didUpdate) {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted > 0) {
|
||||
locationLogger.info(`📍 为 ${attempted} 张缺失位置信息的照片尝试补全,成功 ${updated} 张`)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type { GeocodingPluginOptions }
|
||||
@@ -36,16 +36,8 @@ export interface SystemBuilderSettings {
|
||||
observability: SystemObservabilitySettings
|
||||
}
|
||||
|
||||
export interface UserGeocodingSettings {
|
||||
enableGeocoding: boolean
|
||||
geocodingProvider: 'mapbox' | 'nominatim' | 'auto'
|
||||
mapboxToken?: string
|
||||
nominatimBaseUrl?: string
|
||||
}
|
||||
|
||||
export interface UserBuilderSettings {
|
||||
storage: StorageConfig | null
|
||||
geocoding: UserGeocodingSettings
|
||||
}
|
||||
|
||||
export interface BuilderConfig {
|
||||
|
||||
Reference in New Issue
Block a user