mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
@@ -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": "埋め込み動画を抽出しています..."
|
||||
}
|
||||
|
||||
@@ -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": "내장된 비디오 추출 중..."
|
||||
}
|
||||
|
||||
@@ -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": "正在提取嵌入的视频..."
|
||||
}
|
||||
|
||||
@@ -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": "正在提取嵌入的影片..."
|
||||
}
|
||||
|
||||
@@ -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": "正在提取嵌入的影片..."
|
||||
}
|
||||
|
||||
@@ -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 两种提供者
|
||||
- 智能缓存和速率限制
|
||||
- 自动重试和并发安全
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新的图像处理功能
|
||||
|
||||
@@ -13,6 +13,8 @@ export {
|
||||
processPhotoWithPipeline,
|
||||
} from './photo/image-pipeline.js'
|
||||
export type { PhotoProcessorOptions } from './photo/processor.js'
|
||||
export type { GeocodingPluginOptions } from './plugins/geocoding.js'
|
||||
export { default as geocodingPlugin } from './plugins/geocoding.js'
|
||||
export type { GitHubRepoSyncPluginOptions } from './plugins/github-repo-sync.js'
|
||||
export { createGitHubRepoSyncPlugin, default as githubRepoSyncPlugin } from './plugins/github-repo-sync.js'
|
||||
export type { B2StoragePluginOptions } from './plugins/storage/b2.js'
|
||||
|
||||
@@ -12,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',
|
||||
}),
|
||||
],
|
||||
}))
|
||||
```
|
||||
|
||||
### 扩展性
|
||||
|
||||
新的模块化设计使得扩展新功能变得更加容易:
|
||||
|
||||
480
packages/builder/src/photo/geocoding.ts
Normal file
480
packages/builder/src/photo/geocoding.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
326
packages/builder/src/plugins/geocoding.ts
Normal file
326
packages/builder/src/plugins/geocoding.ts
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user