From 58a989c1e43eaf842d3af6ef120b2c98dcf368bf Mon Sep 17 00:00:00 2001 From: Wenzhuo Liu Date: Mon, 24 Nov 2025 14:48:57 +0800 Subject: [PATCH] feat(geocoding): implement reverse geocoding (#157) Co-authored-by: Innei Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/ui/photo-viewer/ExifPanel.tsx | 21 + locales/app/en.json | 8 +- locales/app/jp.json | 8 +- locales/app/ko.json | 8 +- locales/app/zh-CN.json | 8 +- locales/app/zh-HK.json | 8 +- locales/app/zh-TW.json | 8 +- packages/builder/README.md | 11 +- packages/builder/src/index.ts | 2 + packages/builder/src/photo/README.md | 30 +- packages/builder/src/photo/geocoding.ts | 480 ++++++++++++++++++ packages/builder/src/photo/image-pipeline.ts | 7 +- packages/builder/src/photo/logger-adapter.ts | 2 + packages/builder/src/plugins/geocoding.ts | 326 ++++++++++++ packages/builder/src/types/photo.ts | 10 + 15 files changed, 911 insertions(+), 26 deletions(-) create mode 100644 packages/builder/src/photo/geocoding.ts create mode 100644 packages/builder/src/plugins/geocoding.ts 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