diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
index 5174edc6..bbe51b9f 100644
--- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
+++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
@@ -390,6 +390,27 @@ export const ExifPanel: FC<{
)}
+ {/* 反向地理编码位置信息 */}
+ {currentPhoto.location && (
+
+ {(currentPhoto.location.city || currentPhoto.location.country) && (
+
+ )}
+ {currentPhoto.location.locationName && (
+
+ )}
+
+ )}
+
{/* Maplibre MiniMap */}
{decimalLatitude !== null && decimalLongitude !== null && (
diff --git a/locales/app/en.json b/locales/app/en.json
index 73e6cbdc..44ac4edd 100644
--- a/locales/app/en.json
+++ b/locales/app/en.json
@@ -175,7 +175,9 @@
"exif.fujirecipe-sharpness.soft": "Soft",
"exif.fujirecipe-whitebalance.auto": "Auto",
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
+ "exif.gps.address": "Address",
"exif.gps.altitude": "Altitude",
+ "exif.gps.city": "City",
"exif.gps.latitude": "Latitude",
"exif.gps.location.info": "Location Information",
"exif.gps.location.name": "Location Name",
@@ -358,7 +360,6 @@
"slider.columns": "{{count}} column",
"video.codec.keyword": "Encoder",
"video.conversion.cached.result": "Using cached result",
- "video.motion-photo.extracting": "Extracting embedded video...",
"video.conversion.codec.fallback": "No MP4 codec found that supports this resolution. Falling back to WebM.",
"video.conversion.complete": "Conversion complete",
"video.conversion.converting": "Converting... {{current}}/{{total}} frames",
@@ -378,5 +379,6 @@
"video.conversion.webcodecs.high.quality": "Using high-quality WebCodecs converter...",
"video.conversion.webcodecs.not.supported": "WebCodecs is not supported in this browser",
"video.format.mov.not.supported": "Browser does not support MOV format, conversion required",
- "video.format.mov.supported": "Browser natively supports MOV format, skipping conversion"
-}
\ No newline at end of file
+ "video.format.mov.supported": "Browser natively supports MOV format, skipping conversion",
+ "video.motion-photo.extracting": "Extracting embedded video..."
+}
diff --git a/locales/app/jp.json b/locales/app/jp.json
index 82e5651c..03c431de 100644
--- a/locales/app/jp.json
+++ b/locales/app/jp.json
@@ -172,7 +172,9 @@
"exif.fujirecipe-sharpness.soft": "軟調",
"exif.fujirecipe-whitebalance.auto": "自動",
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
+ "exif.gps.address": "住所",
"exif.gps.altitude": "高度",
+ "exif.gps.city": "都市",
"exif.gps.latitude": "緯度",
"exif.gps.location.info": "位置情報",
"exif.gps.location.name": "位置名",
@@ -351,7 +353,6 @@
"slider.columns": "{{count}} 列",
"video.codec.keyword": "エンコーダー",
"video.conversion.cached.result": "キャッシュされた結果を使用",
- "video.motion-photo.extracting": "埋め込み動画を抽出しています...",
"video.conversion.codec.fallback": "この解像度でサポートされている MP4 コーデックが見つかりません。WebM にフォールバックします。",
"video.conversion.complete": "変換完了",
"video.conversion.converting": "変換中... {{current}}/{{total}}フレーム",
@@ -371,5 +372,6 @@
"video.conversion.webcodecs.high.quality": "高品質の WebCodecs コンバーターを使用しています...",
"video.conversion.webcodecs.not.supported": "このブラウザは WebCodecs をサポートしていません",
"video.format.mov.not.supported": "ブラウザが MOV 形式をサポートしていないため、変換が必要です",
- "video.format.mov.supported": "ブラウザが MOV 形式をネイティブでサポートしているため、変換をスキップします"
-}
\ No newline at end of file
+ "video.format.mov.supported": "ブラウザが MOV 形式をネイティブでサポートしているため、変換をスキップします",
+ "video.motion-photo.extracting": "埋め込み動画を抽出しています..."
+}
diff --git a/locales/app/ko.json b/locales/app/ko.json
index 18d69023..a117f7c7 100644
--- a/locales/app/ko.json
+++ b/locales/app/ko.json
@@ -172,7 +172,9 @@
"exif.fujirecipe-sharpness.soft": "소프트",
"exif.fujirecipe-whitebalance.auto": "자동",
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
+ "exif.gps.address": "주소",
"exif.gps.altitude": "고도",
+ "exif.gps.city": "도시",
"exif.gps.latitude": "위도",
"exif.gps.location.info": "위치 정보",
"exif.gps.location.name": "위치 이름",
@@ -351,7 +353,6 @@
"slider.columns": "{{count}} 열",
"video.codec.keyword": "인코더",
"video.conversion.cached.result": "캐시된 결과 사용",
- "video.motion-photo.extracting": "내장된 비디오 추출 중...",
"video.conversion.codec.fallback": "이 해상도에서 지원되는 MP4 코덱을 찾을 수 없습니다. WebM 으로 대체합니다.",
"video.conversion.complete": "변환 완료",
"video.conversion.converting": "변환 중... {{current}}/{{total}} 프레임",
@@ -371,5 +372,6 @@
"video.conversion.webcodecs.high.quality": "고품질 WebCodecs 변환기 사용 중...",
"video.conversion.webcodecs.not.supported": "이 브라우저는 WebCodecs 를 지원하지 않습니다",
"video.format.mov.not.supported": "브라우저가 MOV 형식을 지원하지 않아 변환이 필요합니다.",
- "video.format.mov.supported": "브라우저가 MOV 형식을 기본적으로 지원하므로 변환을 건너뜁니다."
-}
\ No newline at end of file
+ "video.format.mov.supported": "브라우저가 MOV 형식을 기본적으로 지원하므로 변환을 건너뜁니다.",
+ "video.motion-photo.extracting": "내장된 비디오 추출 중..."
+}
diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json
index 2d7485c7..45a78212 100644
--- a/locales/app/zh-CN.json
+++ b/locales/app/zh-CN.json
@@ -172,7 +172,9 @@
"exif.fujirecipe-sharpness.soft": "柔和",
"exif.fujirecipe-whitebalance.auto": "自动",
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
+ "exif.gps.address": "地址",
"exif.gps.altitude": "海拔",
+ "exif.gps.city": "城市",
"exif.gps.latitude": "纬度",
"exif.gps.location.info": "位置信息",
"exif.gps.location.name": "位置名称",
@@ -355,7 +357,6 @@
"slider.columns": "{{count}} 列",
"video.codec.keyword": "编码器",
"video.conversion.cached.result": "使用缓存结果",
- "video.motion-photo.extracting": "正在提取嵌入的视频...",
"video.conversion.codec.fallback": "找不到此分辨率支持的 MP4 编解码器。回退到 WebM。",
"video.conversion.complete": "转换完成",
"video.conversion.converting": "转换中... {{current}}/{{total}} 帧",
@@ -375,5 +376,6 @@
"video.conversion.webcodecs.high.quality": "使用高质量 WebCodecs 转换器...",
"video.conversion.webcodecs.not.supported": "此浏览器不支持 WebCodecs",
"video.format.mov.not.supported": "浏览器不支持 MOV 格式,需要转换",
- "video.format.mov.supported": "浏览器原生支持 MOV 格式,跳过转换"
-}
\ No newline at end of file
+ "video.format.mov.supported": "浏览器原生支持 MOV 格式,跳过转换",
+ "video.motion-photo.extracting": "正在提取嵌入的视频..."
+}
diff --git a/locales/app/zh-HK.json b/locales/app/zh-HK.json
index c481ac19..8ae34a5d 100644
--- a/locales/app/zh-HK.json
+++ b/locales/app/zh-HK.json
@@ -172,7 +172,9 @@
"exif.fujirecipe-sharpness.soft": "柔和",
"exif.fujirecipe-whitebalance.auto": "自動",
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
+ "exif.gps.address": "地址",
"exif.gps.altitude": "海拔",
+ "exif.gps.city": "城市",
"exif.gps.latitude": "緯度",
"exif.gps.location.info": "位置信息",
"exif.gps.location.name": "位置名稱",
@@ -351,7 +353,6 @@
"slider.columns": "{{count}} 列",
"video.codec.keyword": "編碼器",
"video.conversion.cached.result": "使用快取結果",
- "video.motion-photo.extracting": "正在提取嵌入的影片...",
"video.conversion.codec.fallback": "找不到此解析度支援的 MP4 編解碼器。回退到 WebM。",
"video.conversion.complete": "轉換完成",
"video.conversion.converting": "轉換中... {{current}}/{{total}} 幀",
@@ -371,5 +372,6 @@
"video.conversion.webcodecs.high.quality": "使用高品質 WebCodecs 轉換器...",
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
"video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換",
- "video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換"
-}
\ No newline at end of file
+ "video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換",
+ "video.motion-photo.extracting": "正在提取嵌入的影片..."
+}
diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json
index 36c35d4b..fde67740 100644
--- a/locales/app/zh-TW.json
+++ b/locales/app/zh-TW.json
@@ -172,7 +172,9 @@
"exif.fujirecipe-sharpness.soft": "柔和",
"exif.fujirecipe-whitebalance.auto": "自動",
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
+ "exif.gps.address": "地址",
"exif.gps.altitude": "海拔",
+ "exif.gps.city": "城市",
"exif.gps.latitude": "緯度",
"exif.gps.location.info": "位置信息",
"exif.gps.location.name": "位置名稱",
@@ -350,7 +352,6 @@
"slider.columns": "{{count}} 列",
"video.codec.keyword": "編碼器",
"video.conversion.cached.result": "使用快取結果",
- "video.motion-photo.extracting": "正在提取嵌入的影片...",
"video.conversion.codec.fallback": "找不到此解析度支援的 MP4 編解碼器。回退到 WebM。",
"video.conversion.complete": "轉換完成",
"video.conversion.converting": "轉換中... {{current}}/{{total}} 幀",
@@ -370,5 +371,6 @@
"video.conversion.webcodecs.high.quality": "使用高品質 WebCodecs 轉換器...",
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
"video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換",
- "video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換"
-}
\ No newline at end of file
+ "video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換",
+ "video.motion-photo.extracting": "正在提取嵌入的影片..."
+}
diff --git a/packages/builder/README.md b/packages/builder/README.md
index 9e1c7f4d..16b09579 100644
--- a/packages/builder/README.md
+++ b/packages/builder/README.md
@@ -20,7 +20,8 @@ src/core/
│ └── exif.ts # EXIF 数据提取
├── photo/ # 照片处理
│ ├── info-extractor.ts # 照片信息提取
-│ └── processor.ts # 照片处理主逻辑
+│ ├── processor.ts # 照片处理主逻辑
+│ └── geocoding.ts # 反向地理编码
├── manifest/ # Manifest 管理
│ └── manager.ts # Manifest 读写和管理
├── worker/ # 并发处理
@@ -62,6 +63,7 @@ src/core/
- **info-extractor.ts**: 从文件名和 EXIF 提取照片信息
- **processor.ts**: 照片处理主流程,整合所有处理步骤
+- **geocoding.ts**: 反向地理编码,支持 Mapbox 和 Nominatim 提供者
### 6. Manifest 管理 (`manifest/`)
@@ -136,6 +138,13 @@ const exif = await extractExifData(buffer)
- 可配置的并发数
- 环境变量配置
+### 6. 地理编码支持
+
+- 从 GPS 坐标提取位置信息
+- 支持 Mapbox 和 Nominatim 两种提供者
+- 智能缓存和速率限制
+- 自动重试和并发安全
+
## 扩展指南
### 添加新的图像处理功能
diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts
index 21a0832a..d89c8b49 100644
--- a/packages/builder/src/index.ts
+++ b/packages/builder/src/index.ts
@@ -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'
diff --git a/packages/builder/src/photo/README.md b/packages/builder/src/photo/README.md
index 8bd37518..b667bda7 100644
--- a/packages/builder/src/photo/README.md
+++ b/packages/builder/src/photo/README.md
@@ -12,6 +12,7 @@
- **`live-photo-handler.ts`** - Live Photo 检测和处理
- **`logger-adapter.ts`** - Logger 适配器,实现适配器模式
- **`info-extractor.ts`** - 照片信息提取
+- **`geocoding.ts`** - 反向地理编码提供者定义(通过 geocoding 插件调用)
### 设计模式
@@ -42,9 +43,9 @@ class CompatibleLoggerAdapter implements PhotoLogger {
2. 创建 Sharp 实例
3. 处理缩略图和 blurhash
4. 处理 EXIF 数据
-5. 处理影调分析
-6. 提取照片信息
-7. 处理 Live Photo
+5. HDR / Motion Photo / Live Photo 检测
+6. 处理影调分析
+7. 提取照片信息
8. 构建照片清单项
### 主要改进
@@ -53,7 +54,8 @@ class CompatibleLoggerAdapter implements PhotoLogger {
2. **Logger 适配器**: 使用异步执行上下文管理 logger,避免全局状态污染
3. **缓存管理**: 统一管理各种数据的缓存和复用逻辑
4. **Live Photo 处理**: 专门的模块处理 Live Photo 检测和匹配
-5. **类型安全**: 完善的 TypeScript 类型定义
+5. **反向地理编码插件**: 通过 geocoding 插件在构建生命周期中写入位置信息,支持多个地理编码提供商
+6. **类型安全**: 完善的 TypeScript 类型定义
### 使用方法
@@ -92,6 +94,26 @@ const thumbnailResult = await processThumbnailAndBlurhash(imageBuffer, photoId,
const exifData = await processExifData(imageBuffer, rawImageBuffer, 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,
+ // language: 'en,zh', // 可选,按需设置语言
+ // nominatimBaseUrl: 'https://your-nominatim-instance.com',
+ }),
+ ],
+}))
+```
+
### 扩展性
新的模块化设计使得扩展新功能变得更加容易:
diff --git a/packages/builder/src/photo/geocoding.ts b/packages/builder/src/photo/geocoding.ts
new file mode 100644
index 00000000..25c90768
--- /dev/null
+++ b/packages/builder/src/photo/geocoding.ts
@@ -0,0 +1,480 @@
+import { createHash } from 'node:crypto'
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+
+import type { LocationInfo, PickedExif } from '../types/photo.js'
+import { getGlobalLoggers } from './logger-adapter.js'
+
+const sleep = (ms: number): Promise
=> new Promise((resolve) => setTimeout(resolve, ms))
+const getBackoffDelay = (attempt: number, baseDelay: number): number => {
+ const exponential = baseDelay * 2 ** (attempt - 1)
+ const jitter = Math.random() * baseDelay
+ return exponential + jitter
+}
+
+const INTERPROCESS_RATE_LIMIT_DIR = path.join(os.tmpdir(), 'afilmory-geocoding-rate-limit')
+const LOCK_RETRY_DELAY_MS = 50
+const LOCK_STALE_TIMEOUT_MS = 5 * 60_000
+let rateLimitDirReady: Promise | null = null
+
+const ensureRateLimitDir = async (): Promise => {
+ if (!rateLimitDirReady) {
+ rateLimitDirReady = fs.mkdir(INTERPROCESS_RATE_LIMIT_DIR, { recursive: true }).then(() => {})
+ }
+ await rateLimitDirReady
+}
+
+const hashKey = (key: string): string => createHash('sha1').update(key).digest('hex')
+
+const getRateLimitPaths = (key: string): { lockPath: string; timestampPath: string } => {
+ const hashedKey = hashKey(key)
+ return {
+ lockPath: path.join(INTERPROCESS_RATE_LIMIT_DIR, `${hashedKey}.lock`),
+ timestampPath: path.join(INTERPROCESS_RATE_LIMIT_DIR, `${hashedKey}.ts`),
+ }
+}
+
+async function tryRemoveLock(lockPath: string): Promise {
+ await fs.rm(lockPath, { force: true }).catch(() => {})
+}
+
+const isLockStale = async (lockPath: string): Promise => {
+ try {
+ const stat = await fs.stat(lockPath)
+ return Date.now() - stat.mtimeMs > LOCK_STALE_TIMEOUT_MS
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return false
+ }
+ throw error
+ }
+}
+
+async function withInterprocessLock(key: string, fn: () => Promise): Promise {
+ await ensureRateLimitDir()
+ const { lockPath } = getRateLimitPaths(key)
+
+ while (true) {
+ try {
+ const handle = await fs.open(lockPath, 'wx')
+ await handle.write(`${process.pid}:${Date.now()}`)
+ await handle.close()
+
+ try {
+ const result = await fn()
+ return result
+ } finally {
+ await tryRemoveLock(lockPath)
+ }
+ } catch (error) {
+ const nodeError = error as NodeJS.ErrnoException
+ if (nodeError.code === 'EEXIST') {
+ if (await isLockStale(lockPath)) {
+ await tryRemoveLock(lockPath)
+ continue
+ }
+ await sleep(LOCK_RETRY_DELAY_MS)
+ continue
+ }
+ throw error
+ }
+ }
+}
+
+const applyInterprocessRateLimit = async (key: string, intervalMs: number): Promise => {
+ const { timestampPath } = getRateLimitPaths(key)
+
+ await withInterprocessLock(key, async () => {
+ let lastRequestTime = 0
+ try {
+ const stat = await fs.stat(timestampPath)
+ lastRequestTime = stat.mtimeMs
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ throw error
+ }
+ }
+
+ const now = Date.now()
+ const elapsed = now - lastRequestTime
+ if (elapsed < intervalMs) {
+ await sleep(intervalMs - elapsed)
+ }
+
+ await fs.writeFile(timestampPath, `${Date.now()}`)
+ })
+}
+
+class SequentialRateLimiter {
+ private queue: Promise = Promise.resolve()
+ private lastTimestamp = 0
+
+ constructor(private readonly intervalMs: number) {}
+
+ wait(): Promise {
+ this.queue = this.queue.then(async () => {
+ const now = Date.now()
+ const elapsed = now - this.lastTimestamp
+ const delay = elapsed < this.intervalMs ? this.intervalMs - elapsed : 0
+
+ if (delay > 0) {
+ await sleep(delay)
+ }
+
+ this.lastTimestamp = Date.now()
+ })
+
+ return this.queue
+ }
+}
+
+interface RateLimiterRegistryGlobal {
+ __afilmoryGeocodingRateLimiters?: Map
+}
+
+const getGlobalRateLimiter = (key: string, intervalMs: number): SequentialRateLimiter => {
+ const globalObject = globalThis as typeof globalThis & RateLimiterRegistryGlobal
+
+ if (!globalObject.__afilmoryGeocodingRateLimiters) {
+ globalObject.__afilmoryGeocodingRateLimiters = new Map()
+ }
+
+ const existing = globalObject.__afilmoryGeocodingRateLimiters.get(key)
+ if (existing) {
+ return existing
+ }
+
+ const limiter = new SequentialRateLimiter(intervalMs)
+ globalObject.__afilmoryGeocodingRateLimiters.set(key, limiter)
+ return limiter
+}
+
+/**
+ * 地理编码提供者接口
+ */
+export interface GeocodingProvider {
+ reverseGeocode: (lat: number, lon: number) => Promise
+}
+
+/**
+ * Mapbox 地理编码提供者
+ * 高精度商业地理编码服务,支持全球范围和多语言
+ */
+export class MapboxGeocodingProvider implements GeocodingProvider {
+ private readonly accessToken: string
+ private readonly language: string | null
+ private readonly baseUrl = 'https://api.mapbox.com'
+ private readonly rateLimitMs = 100 // Mapbox 速率限制:1000次/分钟
+ private readonly rateLimiter: SequentialRateLimiter
+ private readonly interprocessKey: string
+ private readonly maxRetries = 3
+ private readonly retryBaseDelayMs = 500
+
+ constructor(accessToken: string, language?: string | null) {
+ this.accessToken = accessToken
+ this.language = language ?? null
+ this.rateLimiter = getGlobalRateLimiter(`mapbox:${accessToken}`, this.rateLimitMs)
+ this.interprocessKey = `mapbox:${accessToken}`
+ }
+
+ async reverseGeocode(lat: number, lon: number): Promise {
+ const log = getGlobalLoggers().location
+
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
+ try {
+ await this.applyRateLimit()
+
+ const url = new URL('/search/geocode/v6/reverse', this.baseUrl)
+ url.searchParams.set('access_token', this.accessToken)
+ url.searchParams.set('longitude', lon.toString())
+ url.searchParams.set('latitude', lat.toString())
+ if (this.language) {
+ url.searchParams.set('language', this.language)
+ }
+
+ log.info(`调用 Mapbox API: ${lat}, ${lon}`)
+
+ const response = await fetch(url.toString())
+
+ if (!response.ok) {
+ throw new Error(`Mapbox API 错误: ${response.status} ${response.statusText}`)
+ }
+
+ const data = await response.json()
+
+ if (!data || !data.features || data.features.length === 0) {
+ log.warn('Mapbox API 未返回结果')
+ return null
+ }
+
+ // 取第一个最相关的结果
+ const feature = data.features[0]
+ const properties = feature.properties || {}
+ const context = properties.context || {}
+
+ // 提取国家信息
+ const country = context.country?.name
+
+ // 提取城市信息 - 拼接多个层级(从小到大)
+ const cityParts = [
+ context.locality?.name,
+ context.neighborhood?.name,
+ context.district?.name,
+ context.place?.name,
+ context.region?.name,
+ ].filter(Boolean)
+
+ // 去重并拼接(保持顺序,最多取2个层级)
+ const uniqueCityParts = [...new Set(cityParts)].slice(0, 2)
+ const city = uniqueCityParts.length > 0 ? uniqueCityParts.join(', ') : undefined
+
+ // 构建位置名称
+ const locationName = properties.place_formatted || properties.name
+
+ log.success(`成功获取位置: ${city}, ${country}`)
+
+ return {
+ latitude: lat,
+ longitude: lon,
+ country,
+ city,
+ locationName,
+ }
+ } catch (error) {
+ const isLastAttempt = attempt === this.maxRetries
+ if (isLastAttempt) {
+ log.error('Mapbox 反向地理编码失败:', error)
+ break
+ }
+
+ const delay = getBackoffDelay(attempt, this.retryBaseDelayMs)
+ log.warn(`Mapbox API 调用失败,${Math.round(delay)}ms 后重试 (${attempt}/${this.maxRetries})`, error)
+ await sleep(delay)
+ }
+ }
+
+ return null
+ }
+
+ private async applyRateLimit(): Promise {
+ await this.rateLimiter.wait()
+ await applyInterprocessRateLimit(this.interprocessKey, this.rateLimitMs)
+ }
+}
+
+/**
+ * OpenStreetMap Nominatim API 地理编码提供者
+ * 免费的地理编码服务,适合开发和小规模使用
+ */
+export class NominatimGeocodingProvider implements GeocodingProvider {
+ private readonly baseUrl: string
+ private readonly language: string | null
+ private readonly userAgent = 'afilmory/1.0'
+ private readonly rateLimitMs = 1000 // Nominatim 要求至少1秒间隔
+ private readonly rateLimiter: SequentialRateLimiter
+ private readonly interprocessKey: string
+ private readonly maxRetries = 3
+ private readonly retryBaseDelayMs = 1000
+
+ constructor(baseUrl?: string, language?: string | null) {
+ this.baseUrl = baseUrl || 'https://nominatim.openstreetmap.org'
+ this.language = language ?? null
+ this.rateLimiter = getGlobalRateLimiter(`nominatim:${this.baseUrl}`, this.rateLimitMs)
+ this.interprocessKey = `nominatim:${this.baseUrl}`
+ }
+
+ async reverseGeocode(lat: number, lon: number): Promise {
+ const log = getGlobalLoggers().location
+
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
+ try {
+ await this.applyRateLimit()
+
+ const url = new URL('/reverse', this.baseUrl)
+ url.searchParams.set('lat', lat.toString())
+ url.searchParams.set('lon', lon.toString())
+ url.searchParams.set('format', 'json')
+ url.searchParams.set('addressdetails', '1')
+ if (this.language) {
+ url.searchParams.set('accept-language', this.language)
+ }
+
+ log.info(`调用 Nominatim API: ${lat}, ${lon}`)
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ 'User-Agent': this.userAgent,
+ ...(this.language ? { 'Accept-Language': this.language } : {}),
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`Nominatim API 错误: ${response.status} ${response.statusText}`)
+ }
+
+ const data = await response.json()
+
+ if (!data || data.error) {
+ throw new Error(`Nominatim API 返回错误: ${data?.error}`)
+ }
+
+ const address = data.address || {}
+
+ // 提取国家信息
+ const country = address.country || address.country_code?.toUpperCase()
+
+ // 提取城市信息 - 拼接多个层级(从小到大)
+ const cityParts = [
+ address.village,
+ address.hamlet,
+ address.neighbourhood,
+ address.suburb,
+ address.district,
+ address.city,
+ address.town,
+ address.county,
+ address.state,
+ ].filter(Boolean)
+
+ // 去重并拼接(保持顺序,最多取2个层级)
+ const uniqueCityParts = [...new Set(cityParts)].slice(0, 2)
+ const city = uniqueCityParts.length > 0 ? uniqueCityParts.join(', ') : undefined
+
+ // 构建位置名称
+ const locationName = data.display_name
+
+ log.success(`成功获取位置: ${city}, ${country}`)
+
+ return {
+ latitude: lat,
+ longitude: lon,
+ country,
+ city,
+ locationName,
+ }
+ } catch (error) {
+ const isLastAttempt = attempt === this.maxRetries
+ if (isLastAttempt) {
+ log.error('Nominatim 反向地理编码失败:', error)
+ break
+ }
+
+ const delay = getBackoffDelay(attempt, this.retryBaseDelayMs)
+ log.warn(`Nominatim API 调用失败,${Math.round(delay)}ms 后重试 (${attempt}/${this.maxRetries})`, error)
+ await sleep(delay)
+ }
+ }
+
+ return null
+ }
+
+ private async applyRateLimit(): Promise {
+ await this.rateLimiter.wait()
+ await applyInterprocessRateLimit(this.interprocessKey, this.rateLimitMs)
+ }
+}
+
+/**
+ * 创建地理编码提供者实例
+ * @param provider 提供者类型
+ * @param mapboxToken Mapbox access token(可选)
+ * @param nominatimBaseUrl Nominatim 基础 URL(可选)
+ * @param language 首选语言(可选,逗号分隔的 BCP47 列表)
+ */
+export function createGeocodingProvider(
+ provider: 'mapbox' | 'nominatim' | 'auto',
+ mapboxToken?: string,
+ nominatimBaseUrl?: string,
+ language?: string | null,
+): GeocodingProvider | null {
+ // 如果指定了 Mapbox 或自动模式且有 token,使用 Mapbox
+ if ((provider === 'mapbox' || provider === 'auto') && mapboxToken) {
+ return new MapboxGeocodingProvider(mapboxToken, language)
+ }
+
+ // 使用 Nominatim
+ if (provider === 'nominatim' || provider === 'auto') {
+ return new NominatimGeocodingProvider(nominatimBaseUrl, language)
+ }
+
+ return null
+}
+
+/**
+ * 从 EXIF GPS 数据中提取坐标
+ * @param exif EXIF 数据
+ * @returns 十进制坐标(latitude, longitude)
+ */
+export function parseGPSCoordinates(exif: PickedExif): {
+ latitude?: number
+ longitude?: number
+} {
+ const log = getGlobalLoggers().location
+
+ try {
+ let latitude: number | undefined
+ let longitude: number | undefined
+
+ // 从 GPSLatitude 和 GPSLongitude 提取
+ if (exif.GPSLatitude !== undefined && exif.GPSLongitude !== undefined) {
+ latitude = Number(exif.GPSLatitude)
+ longitude = Number(exif.GPSLongitude)
+ }
+
+ if (latitude === undefined || longitude === undefined) {
+ return {}
+ }
+
+ // 应用 GPS 参考(南纬为负,西经为负)
+ if (exif.GPSLatitudeRef === 'S' || exif.GPSLatitudeRef === 'South') {
+ latitude = -Math.abs(latitude)
+ }
+ if (exif.GPSLongitudeRef === 'W' || exif.GPSLongitudeRef === 'West') {
+ longitude = -Math.abs(longitude)
+ }
+
+ return { latitude, longitude }
+ } catch (error) {
+ log.error('解析 GPS 坐标失败:', error)
+ return {}
+ }
+}
+
+/**
+ * 从 GPS 坐标提取位置信息(反向地理编码)
+ * @param latitude 纬度
+ * @param longitude 经度
+ * @param provider 地理编码提供者
+ * @returns 位置信息
+ */
+export async function extractLocationFromGPS(
+ latitude: number,
+ longitude: number,
+ provider: GeocodingProvider,
+): Promise {
+ const log = getGlobalLoggers().location
+
+ // 验证坐标范围
+ if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
+ log.warn(`无效的 GPS 坐标: ${latitude}, ${longitude}`)
+ return null
+ }
+
+ log.info(`反向地理编码坐标: ${latitude}, ${longitude}`)
+
+ try {
+ const locationInfo = await provider.reverseGeocode(latitude, longitude)
+
+ if (locationInfo) {
+ log.success(`位置已找到: ${locationInfo.city}, ${locationInfo.country}`)
+ } else {
+ log.warn('未找到位置信息')
+ }
+
+ return locationInfo
+ } catch (error) {
+ log.error('位置提取失败:', error)
+ return null
+ }
+}
diff --git a/packages/builder/src/photo/image-pipeline.ts b/packages/builder/src/photo/image-pipeline.ts
index 41385d67..65e222a2 100644
--- a/packages/builder/src/photo/image-pipeline.ts
+++ b/packages/builder/src/photo/image-pipeline.ts
@@ -201,13 +201,13 @@ export async function executePhotoProcessingPipeline(
throw new Error(errorMsg)
}
- // 7. 处理影调分析
+ // 8. 处理影调分析
const toneAnalysis = await processToneAnalysis(sharpInstance, photoKey, existingItem, options)
- // 8. 提取照片信息
+ // 9. 提取照片信息
const photoInfo = extractPhotoInfo(photoKey, exifData)
- // 9. 构建照片清单项
+ // 10. 构建照片清单项
const aspectRatio = metadata.width / metadata.height
const photoItem: PhotoManifestItem = {
id: photoId,
@@ -227,6 +227,7 @@ export async function executePhotoProcessingPipeline(
digest: contentDigest,
exif: exifData,
toneAnalysis,
+ location: existingItem?.location ?? null,
// Video source (Motion Photo or Live Photo)
video:
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
diff --git a/packages/builder/src/photo/logger-adapter.ts b/packages/builder/src/photo/logger-adapter.ts
index 1f800280..ba04ac17 100644
--- a/packages/builder/src/photo/logger-adapter.ts
+++ b/packages/builder/src/photo/logger-adapter.ts
@@ -101,6 +101,7 @@ export interface PhotoProcessingLoggers {
blurhash: CompatibleLoggerAdapter
exif: CompatibleLoggerAdapter
tone: CompatibleLoggerAdapter
+ location: CompatibleLoggerAdapter
}
/**
@@ -115,6 +116,7 @@ export function createPhotoProcessingLoggers(workerId: number, baseLogger: Logge
blurhash: new CompatibleLoggerAdapter(workerLogger.withTag('BLURHASH')),
exif: new CompatibleLoggerAdapter(workerLogger.withTag('EXIF')),
tone: new CompatibleLoggerAdapter(workerLogger.withTag('TONE')),
+ location: new CompatibleLoggerAdapter(workerLogger.withTag('LOCATION')),
}
}
diff --git a/packages/builder/src/plugins/geocoding.ts b/packages/builder/src/plugins/geocoding.ts
new file mode 100644
index 00000000..df9fa2f4
--- /dev/null
+++ b/packages/builder/src/plugins/geocoding.ts
@@ -0,0 +1,326 @@
+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
+ /**
+ * Preferred languages for geocoding results (BCP47). Accepts comma-separated string or array.
+ */
+ language?: string | string[]
+}
+type GeocodingPluginOptionsResolved = Required> &
+ Pick & {
+ language: string | null
+ }
+
+interface ResolvedGeocodingSettings {
+ provider: 'mapbox' | 'nominatim' | 'auto'
+ mapboxToken?: string
+ nominatimBaseUrl?: string
+ cachePrecision: number
+ language: string | null
+}
+
+interface GeocodingState {
+ provider: GeocodingProvider | null
+ providerKey: string | null
+ cache: Map
+}
+
+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),
+ language: normalizeLanguage(options.language),
+ }
+}
+
+function getOrCreateState(runShared: Map): 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 ?? ''}:${settings.language ?? ''}`
+}
+
+function ensureProvider(
+ state: GeocodingState,
+ settings: ResolvedGeocodingSettings,
+ logger: LocationLogger,
+): GeocodingProvider | null {
+ const providerKey = buildProviderKey(settings)
+ if (state.provider && state.providerKey === providerKey) {
+ return state.provider
+ }
+
+ if (state.providerKey && state.providerKey !== providerKey) {
+ state.cache.clear()
+ }
+
+ const provider = createGeocodingProvider(
+ settings.provider,
+ settings.mapboxToken,
+ settings.nominatimBaseUrl,
+ settings.language ?? undefined,
+ )
+
+ 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(builder: AfilmoryBuilder, logger: Logger, fn: () => Promise): Promise {
+ 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 {
+ 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 }
diff --git a/packages/builder/src/types/photo.ts b/packages/builder/src/types/photo.ts
index 00ee0cea..e7d9d34f 100644
--- a/packages/builder/src/types/photo.ts
+++ b/packages/builder/src/types/photo.ts
@@ -1,5 +1,14 @@
import type { Tags } from 'exiftool-vendored'
+// 地理位置信息
+export interface LocationInfo {
+ latitude: number
+ longitude: number
+ country?: string
+ city?: string
+ locationName?: string
+}
+
// 影调类型定义
export type ToneType = 'low-key' | 'high-key' | 'normal' | 'high-contrast'
@@ -60,6 +69,7 @@ export interface PhotoManifestItem extends PhotoInfo {
digest?: string
exif: PickedExif | null
toneAnalysis: ToneAnalysis | null // 影调分析结果
+ location: LocationInfo | null // 地理位置信息(反向地理编码)
isHDR?: boolean
// Video source (Live Photo or Motion Photo)
video?: VideoSource