feat(geocoding): implement reverse geocoding (#157)

Co-authored-by: Innei <tukon479@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Wenzhuo Liu
2025-11-24 14:48:57 +08:00
committed by GitHub
parent a56f6aac4e
commit 58a989c1e4
15 changed files with 911 additions and 26 deletions

View File

@@ -390,6 +390,27 @@ export const ExifPanel: FC<{
<Row label={t('exif.gps.altitude')} value={`${formattedExifData.gps.altitude}m`} />
)}
{/* 反向地理编码位置信息 */}
{currentPhoto.location && (
<div className="mt-3 space-y-1">
{(currentPhoto.location.city || currentPhoto.location.country) && (
<Row
label={t('exif.gps.city')}
value={[currentPhoto.location.city, currentPhoto.location.country]
.filter(Boolean)
.join(', ')}
/>
)}
{currentPhoto.location.locationName && (
<Row
label={t('exif.gps.address')}
value={currentPhoto.location.locationName}
ellipsis={true}
/>
)}
</div>
)}
{/* Maplibre MiniMap */}
{decimalLatitude !== null && decimalLongitude !== null && (
<div className="mt-3">

View File

@@ -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"
}
"video.format.mov.supported": "Browser natively supports MOV format, skipping conversion",
"video.motion-photo.extracting": "Extracting embedded video..."
}

View File

@@ -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 形式をネイティブでサポートしているため、変換をスキップします"
}
"video.format.mov.supported": "ブラウザが MOV 形式をネイティブでサポートしているため、変換をスキップします",
"video.motion-photo.extracting": "埋め込み動画を抽出しています..."
}

View File

@@ -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 형식을 기본적으로 지원하므로 변환을 건너뜁니다."
}
"video.format.mov.supported": "브라우저가 MOV 형식을 기본적으로 지원하므로 변환을 건너뜁니다.",
"video.motion-photo.extracting": "내장된 비디오 추출 중..."
}

View File

@@ -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 格式,跳过转换"
}
"video.format.mov.supported": "浏览器原生支持 MOV 格式,跳过转换",
"video.motion-photo.extracting": "正在提取嵌入的视频..."
}

View File

@@ -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 格式,跳過轉換"
}
"video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換",
"video.motion-photo.extracting": "正在提取嵌入的影片..."
}

View File

@@ -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 格式,跳過轉換"
}
"video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換",
"video.motion-photo.extracting": "正在提取嵌入的影片..."
}

View File

@@ -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 两种提供者
- 智能缓存和速率限制
- 自动重试和并发安全
## 扩展指南
### 添加新的图像处理功能

View File

@@ -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'

View File

@@ -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',
}),
],
}))
```
### 扩展性
新的模块化设计使得扩展新功能变得更加容易:

View File

@@ -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<void> => 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<void> | null = null
const ensureRateLimitDir = async (): Promise<void> => {
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<void> {
await fs.rm(lockPath, { force: true }).catch(() => {})
}
const isLockStale = async (lockPath: string): Promise<boolean> => {
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<T>(key: string, fn: () => Promise<T>): Promise<T> {
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<void> => {
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<void> = Promise.resolve()
private lastTimestamp = 0
constructor(private readonly intervalMs: number) {}
wait(): Promise<void> {
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<string, SequentialRateLimiter>
}
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<LocationInfo | null>
}
/**
* 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<LocationInfo | null> {
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<void> {
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<LocationInfo | null> {
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<void> {
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<LocationInfo | null> {
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
}
}

View File

@@ -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

View File

@@ -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')),
}
}

View File

@@ -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<GeocodingPluginOptions, 'enable' | 'provider'>> &
Pick<GeocodingPluginOptions, 'mapboxToken' | 'nominatimBaseUrl' | 'cachePrecision'> & {
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<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),
language: normalizeLanguage(options.language),
}
}
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 ?? ''}:${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<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 }

View File

@@ -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