mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +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`} />
|
<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 */}
|
{/* Maplibre MiniMap */}
|
||||||
{decimalLatitude !== null && decimalLongitude !== null && (
|
{decimalLatitude !== null && decimalLongitude !== null && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
|||||||
@@ -175,7 +175,9 @@
|
|||||||
"exif.fujirecipe-sharpness.soft": "Soft",
|
"exif.fujirecipe-sharpness.soft": "Soft",
|
||||||
"exif.fujirecipe-whitebalance.auto": "Auto",
|
"exif.fujirecipe-whitebalance.auto": "Auto",
|
||||||
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
||||||
|
"exif.gps.address": "Address",
|
||||||
"exif.gps.altitude": "Altitude",
|
"exif.gps.altitude": "Altitude",
|
||||||
|
"exif.gps.city": "City",
|
||||||
"exif.gps.latitude": "Latitude",
|
"exif.gps.latitude": "Latitude",
|
||||||
"exif.gps.location.info": "Location Information",
|
"exif.gps.location.info": "Location Information",
|
||||||
"exif.gps.location.name": "Location Name",
|
"exif.gps.location.name": "Location Name",
|
||||||
@@ -358,7 +360,6 @@
|
|||||||
"slider.columns": "{{count}} column",
|
"slider.columns": "{{count}} column",
|
||||||
"video.codec.keyword": "Encoder",
|
"video.codec.keyword": "Encoder",
|
||||||
"video.conversion.cached.result": "Using cached result",
|
"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.codec.fallback": "No MP4 codec found that supports this resolution. Falling back to WebM.",
|
||||||
"video.conversion.complete": "Conversion complete",
|
"video.conversion.complete": "Conversion complete",
|
||||||
"video.conversion.converting": "Converting... {{current}}/{{total}} frames",
|
"video.conversion.converting": "Converting... {{current}}/{{total}} frames",
|
||||||
@@ -378,5 +379,6 @@
|
|||||||
"video.conversion.webcodecs.high.quality": "Using high-quality WebCodecs converter...",
|
"video.conversion.webcodecs.high.quality": "Using high-quality WebCodecs converter...",
|
||||||
"video.conversion.webcodecs.not.supported": "WebCodecs is not supported in this browser",
|
"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.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-sharpness.soft": "軟調",
|
||||||
"exif.fujirecipe-whitebalance.auto": "自動",
|
"exif.fujirecipe-whitebalance.auto": "自動",
|
||||||
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
||||||
|
"exif.gps.address": "住所",
|
||||||
"exif.gps.altitude": "高度",
|
"exif.gps.altitude": "高度",
|
||||||
|
"exif.gps.city": "都市",
|
||||||
"exif.gps.latitude": "緯度",
|
"exif.gps.latitude": "緯度",
|
||||||
"exif.gps.location.info": "位置情報",
|
"exif.gps.location.info": "位置情報",
|
||||||
"exif.gps.location.name": "位置名",
|
"exif.gps.location.name": "位置名",
|
||||||
@@ -351,7 +353,6 @@
|
|||||||
"slider.columns": "{{count}} 列",
|
"slider.columns": "{{count}} 列",
|
||||||
"video.codec.keyword": "エンコーダー",
|
"video.codec.keyword": "エンコーダー",
|
||||||
"video.conversion.cached.result": "キャッシュされた結果を使用",
|
"video.conversion.cached.result": "キャッシュされた結果を使用",
|
||||||
"video.motion-photo.extracting": "埋め込み動画を抽出しています...",
|
|
||||||
"video.conversion.codec.fallback": "この解像度でサポートされている MP4 コーデックが見つかりません。WebM にフォールバックします。",
|
"video.conversion.codec.fallback": "この解像度でサポートされている MP4 コーデックが見つかりません。WebM にフォールバックします。",
|
||||||
"video.conversion.complete": "変換完了",
|
"video.conversion.complete": "変換完了",
|
||||||
"video.conversion.converting": "変換中... {{current}}/{{total}}フレーム",
|
"video.conversion.converting": "変換中... {{current}}/{{total}}フレーム",
|
||||||
@@ -371,5 +372,6 @@
|
|||||||
"video.conversion.webcodecs.high.quality": "高品質の WebCodecs コンバーターを使用しています...",
|
"video.conversion.webcodecs.high.quality": "高品質の WebCodecs コンバーターを使用しています...",
|
||||||
"video.conversion.webcodecs.not.supported": "このブラウザは WebCodecs をサポートしていません",
|
"video.conversion.webcodecs.not.supported": "このブラウザは WebCodecs をサポートしていません",
|
||||||
"video.format.mov.not.supported": "ブラウザが MOV 形式をサポートしていないため、変換が必要です",
|
"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-sharpness.soft": "소프트",
|
||||||
"exif.fujirecipe-whitebalance.auto": "자동",
|
"exif.fujirecipe-whitebalance.auto": "자동",
|
||||||
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
||||||
|
"exif.gps.address": "주소",
|
||||||
"exif.gps.altitude": "고도",
|
"exif.gps.altitude": "고도",
|
||||||
|
"exif.gps.city": "도시",
|
||||||
"exif.gps.latitude": "위도",
|
"exif.gps.latitude": "위도",
|
||||||
"exif.gps.location.info": "위치 정보",
|
"exif.gps.location.info": "위치 정보",
|
||||||
"exif.gps.location.name": "위치 이름",
|
"exif.gps.location.name": "위치 이름",
|
||||||
@@ -351,7 +353,6 @@
|
|||||||
"slider.columns": "{{count}} 열",
|
"slider.columns": "{{count}} 열",
|
||||||
"video.codec.keyword": "인코더",
|
"video.codec.keyword": "인코더",
|
||||||
"video.conversion.cached.result": "캐시된 결과 사용",
|
"video.conversion.cached.result": "캐시된 결과 사용",
|
||||||
"video.motion-photo.extracting": "내장된 비디오 추출 중...",
|
|
||||||
"video.conversion.codec.fallback": "이 해상도에서 지원되는 MP4 코덱을 찾을 수 없습니다. WebM 으로 대체합니다.",
|
"video.conversion.codec.fallback": "이 해상도에서 지원되는 MP4 코덱을 찾을 수 없습니다. WebM 으로 대체합니다.",
|
||||||
"video.conversion.complete": "변환 완료",
|
"video.conversion.complete": "변환 완료",
|
||||||
"video.conversion.converting": "변환 중... {{current}}/{{total}} 프레임",
|
"video.conversion.converting": "변환 중... {{current}}/{{total}} 프레임",
|
||||||
@@ -371,5 +372,6 @@
|
|||||||
"video.conversion.webcodecs.high.quality": "고품질 WebCodecs 변환기 사용 중...",
|
"video.conversion.webcodecs.high.quality": "고품질 WebCodecs 변환기 사용 중...",
|
||||||
"video.conversion.webcodecs.not.supported": "이 브라우저는 WebCodecs 를 지원하지 않습니다",
|
"video.conversion.webcodecs.not.supported": "이 브라우저는 WebCodecs 를 지원하지 않습니다",
|
||||||
"video.format.mov.not.supported": "브라우저가 MOV 형식을 지원하지 않아 변환이 필요합니다.",
|
"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-sharpness.soft": "柔和",
|
||||||
"exif.fujirecipe-whitebalance.auto": "自动",
|
"exif.fujirecipe-whitebalance.auto": "自动",
|
||||||
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
||||||
|
"exif.gps.address": "地址",
|
||||||
"exif.gps.altitude": "海拔",
|
"exif.gps.altitude": "海拔",
|
||||||
|
"exif.gps.city": "城市",
|
||||||
"exif.gps.latitude": "纬度",
|
"exif.gps.latitude": "纬度",
|
||||||
"exif.gps.location.info": "位置信息",
|
"exif.gps.location.info": "位置信息",
|
||||||
"exif.gps.location.name": "位置名称",
|
"exif.gps.location.name": "位置名称",
|
||||||
@@ -355,7 +357,6 @@
|
|||||||
"slider.columns": "{{count}} 列",
|
"slider.columns": "{{count}} 列",
|
||||||
"video.codec.keyword": "编码器",
|
"video.codec.keyword": "编码器",
|
||||||
"video.conversion.cached.result": "使用缓存结果",
|
"video.conversion.cached.result": "使用缓存结果",
|
||||||
"video.motion-photo.extracting": "正在提取嵌入的视频...",
|
|
||||||
"video.conversion.codec.fallback": "找不到此分辨率支持的 MP4 编解码器。回退到 WebM。",
|
"video.conversion.codec.fallback": "找不到此分辨率支持的 MP4 编解码器。回退到 WebM。",
|
||||||
"video.conversion.complete": "转换完成",
|
"video.conversion.complete": "转换完成",
|
||||||
"video.conversion.converting": "转换中... {{current}}/{{total}} 帧",
|
"video.conversion.converting": "转换中... {{current}}/{{total}} 帧",
|
||||||
@@ -375,5 +376,6 @@
|
|||||||
"video.conversion.webcodecs.high.quality": "使用高质量 WebCodecs 转换器...",
|
"video.conversion.webcodecs.high.quality": "使用高质量 WebCodecs 转换器...",
|
||||||
"video.conversion.webcodecs.not.supported": "此浏览器不支持 WebCodecs",
|
"video.conversion.webcodecs.not.supported": "此浏览器不支持 WebCodecs",
|
||||||
"video.format.mov.not.supported": "浏览器不支持 MOV 格式,需要转换",
|
"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-sharpness.soft": "柔和",
|
||||||
"exif.fujirecipe-whitebalance.auto": "自動",
|
"exif.fujirecipe-whitebalance.auto": "自動",
|
||||||
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
||||||
|
"exif.gps.address": "地址",
|
||||||
"exif.gps.altitude": "海拔",
|
"exif.gps.altitude": "海拔",
|
||||||
|
"exif.gps.city": "城市",
|
||||||
"exif.gps.latitude": "緯度",
|
"exif.gps.latitude": "緯度",
|
||||||
"exif.gps.location.info": "位置信息",
|
"exif.gps.location.info": "位置信息",
|
||||||
"exif.gps.location.name": "位置名稱",
|
"exif.gps.location.name": "位置名稱",
|
||||||
@@ -351,7 +353,6 @@
|
|||||||
"slider.columns": "{{count}} 列",
|
"slider.columns": "{{count}} 列",
|
||||||
"video.codec.keyword": "編碼器",
|
"video.codec.keyword": "編碼器",
|
||||||
"video.conversion.cached.result": "使用快取結果",
|
"video.conversion.cached.result": "使用快取結果",
|
||||||
"video.motion-photo.extracting": "正在提取嵌入的影片...",
|
|
||||||
"video.conversion.codec.fallback": "找不到此解析度支援的 MP4 編解碼器。回退到 WebM。",
|
"video.conversion.codec.fallback": "找不到此解析度支援的 MP4 編解碼器。回退到 WebM。",
|
||||||
"video.conversion.complete": "轉換完成",
|
"video.conversion.complete": "轉換完成",
|
||||||
"video.conversion.converting": "轉換中... {{current}}/{{total}} 幀",
|
"video.conversion.converting": "轉換中... {{current}}/{{total}} 幀",
|
||||||
@@ -371,5 +372,6 @@
|
|||||||
"video.conversion.webcodecs.high.quality": "使用高品質 WebCodecs 轉換器...",
|
"video.conversion.webcodecs.high.quality": "使用高品質 WebCodecs 轉換器...",
|
||||||
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
|
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
|
||||||
"video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換",
|
"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-sharpness.soft": "柔和",
|
||||||
"exif.fujirecipe-whitebalance.auto": "自動",
|
"exif.fujirecipe-whitebalance.auto": "自動",
|
||||||
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
"exif.fujirecipe-whitebalance.kelvin": "{{kelvin}}K",
|
||||||
|
"exif.gps.address": "地址",
|
||||||
"exif.gps.altitude": "海拔",
|
"exif.gps.altitude": "海拔",
|
||||||
|
"exif.gps.city": "城市",
|
||||||
"exif.gps.latitude": "緯度",
|
"exif.gps.latitude": "緯度",
|
||||||
"exif.gps.location.info": "位置信息",
|
"exif.gps.location.info": "位置信息",
|
||||||
"exif.gps.location.name": "位置名稱",
|
"exif.gps.location.name": "位置名稱",
|
||||||
@@ -350,7 +352,6 @@
|
|||||||
"slider.columns": "{{count}} 列",
|
"slider.columns": "{{count}} 列",
|
||||||
"video.codec.keyword": "編碼器",
|
"video.codec.keyword": "編碼器",
|
||||||
"video.conversion.cached.result": "使用快取結果",
|
"video.conversion.cached.result": "使用快取結果",
|
||||||
"video.motion-photo.extracting": "正在提取嵌入的影片...",
|
|
||||||
"video.conversion.codec.fallback": "找不到此解析度支援的 MP4 編解碼器。回退到 WebM。",
|
"video.conversion.codec.fallback": "找不到此解析度支援的 MP4 編解碼器。回退到 WebM。",
|
||||||
"video.conversion.complete": "轉換完成",
|
"video.conversion.complete": "轉換完成",
|
||||||
"video.conversion.converting": "轉換中... {{current}}/{{total}} 幀",
|
"video.conversion.converting": "轉換中... {{current}}/{{total}} 幀",
|
||||||
@@ -370,5 +371,6 @@
|
|||||||
"video.conversion.webcodecs.high.quality": "使用高品質 WebCodecs 轉換器...",
|
"video.conversion.webcodecs.high.quality": "使用高品質 WebCodecs 轉換器...",
|
||||||
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
|
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
|
||||||
"video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換",
|
"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 数据提取
|
│ └── exif.ts # EXIF 数据提取
|
||||||
├── photo/ # 照片处理
|
├── photo/ # 照片处理
|
||||||
│ ├── info-extractor.ts # 照片信息提取
|
│ ├── info-extractor.ts # 照片信息提取
|
||||||
│ └── processor.ts # 照片处理主逻辑
|
│ ├── processor.ts # 照片处理主逻辑
|
||||||
|
│ └── geocoding.ts # 反向地理编码
|
||||||
├── manifest/ # Manifest 管理
|
├── manifest/ # Manifest 管理
|
||||||
│ └── manager.ts # Manifest 读写和管理
|
│ └── manager.ts # Manifest 读写和管理
|
||||||
├── worker/ # 并发处理
|
├── worker/ # 并发处理
|
||||||
@@ -62,6 +63,7 @@ src/core/
|
|||||||
|
|
||||||
- **info-extractor.ts**: 从文件名和 EXIF 提取照片信息
|
- **info-extractor.ts**: 从文件名和 EXIF 提取照片信息
|
||||||
- **processor.ts**: 照片处理主流程,整合所有处理步骤
|
- **processor.ts**: 照片处理主流程,整合所有处理步骤
|
||||||
|
- **geocoding.ts**: 反向地理编码,支持 Mapbox 和 Nominatim 提供者
|
||||||
|
|
||||||
### 6. Manifest 管理 (`manifest/`)
|
### 6. Manifest 管理 (`manifest/`)
|
||||||
|
|
||||||
@@ -136,6 +138,13 @@ const exif = await extractExifData(buffer)
|
|||||||
- 可配置的并发数
|
- 可配置的并发数
|
||||||
- 环境变量配置
|
- 环境变量配置
|
||||||
|
|
||||||
|
### 6. 地理编码支持
|
||||||
|
|
||||||
|
- 从 GPS 坐标提取位置信息
|
||||||
|
- 支持 Mapbox 和 Nominatim 两种提供者
|
||||||
|
- 智能缓存和速率限制
|
||||||
|
- 自动重试和并发安全
|
||||||
|
|
||||||
## 扩展指南
|
## 扩展指南
|
||||||
|
|
||||||
### 添加新的图像处理功能
|
### 添加新的图像处理功能
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export {
|
|||||||
processPhotoWithPipeline,
|
processPhotoWithPipeline,
|
||||||
} from './photo/image-pipeline.js'
|
} from './photo/image-pipeline.js'
|
||||||
export type { PhotoProcessorOptions } from './photo/processor.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 type { GitHubRepoSyncPluginOptions } from './plugins/github-repo-sync.js'
|
||||||
export { createGitHubRepoSyncPlugin, default as githubRepoSyncPlugin } 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'
|
export type { B2StoragePluginOptions } from './plugins/storage/b2.js'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- **`live-photo-handler.ts`** - Live Photo 检测和处理
|
- **`live-photo-handler.ts`** - Live Photo 检测和处理
|
||||||
- **`logger-adapter.ts`** - Logger 适配器,实现适配器模式
|
- **`logger-adapter.ts`** - Logger 适配器,实现适配器模式
|
||||||
- **`info-extractor.ts`** - 照片信息提取
|
- **`info-extractor.ts`** - 照片信息提取
|
||||||
|
- **`geocoding.ts`** - 反向地理编码提供者定义(通过 geocoding 插件调用)
|
||||||
|
|
||||||
### 设计模式
|
### 设计模式
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ class CompatibleLoggerAdapter implements PhotoLogger {
|
|||||||
2. 创建 Sharp 实例
|
2. 创建 Sharp 实例
|
||||||
3. 处理缩略图和 blurhash
|
3. 处理缩略图和 blurhash
|
||||||
4. 处理 EXIF 数据
|
4. 处理 EXIF 数据
|
||||||
5. 处理影调分析
|
5. HDR / Motion Photo / Live Photo 检测
|
||||||
6. 提取照片信息
|
6. 处理影调分析
|
||||||
7. 处理 Live Photo
|
7. 提取照片信息
|
||||||
8. 构建照片清单项
|
8. 构建照片清单项
|
||||||
|
|
||||||
### 主要改进
|
### 主要改进
|
||||||
@@ -53,7 +54,8 @@ class CompatibleLoggerAdapter implements PhotoLogger {
|
|||||||
2. **Logger 适配器**: 使用异步执行上下文管理 logger,避免全局状态污染
|
2. **Logger 适配器**: 使用异步执行上下文管理 logger,避免全局状态污染
|
||||||
3. **缓存管理**: 统一管理各种数据的缓存和复用逻辑
|
3. **缓存管理**: 统一管理各种数据的缓存和复用逻辑
|
||||||
4. **Live Photo 处理**: 专门的模块处理 Live Photo 检测和匹配
|
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)
|
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)
|
throw new Error(errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 处理影调分析
|
// 8. 处理影调分析
|
||||||
const toneAnalysis = await processToneAnalysis(sharpInstance, photoKey, existingItem, options)
|
const toneAnalysis = await processToneAnalysis(sharpInstance, photoKey, existingItem, options)
|
||||||
|
|
||||||
// 8. 提取照片信息
|
// 9. 提取照片信息
|
||||||
const photoInfo = extractPhotoInfo(photoKey, exifData)
|
const photoInfo = extractPhotoInfo(photoKey, exifData)
|
||||||
|
|
||||||
// 9. 构建照片清单项
|
// 10. 构建照片清单项
|
||||||
const aspectRatio = metadata.width / metadata.height
|
const aspectRatio = metadata.width / metadata.height
|
||||||
const photoItem: PhotoManifestItem = {
|
const photoItem: PhotoManifestItem = {
|
||||||
id: photoId,
|
id: photoId,
|
||||||
@@ -227,6 +227,7 @@ export async function executePhotoProcessingPipeline(
|
|||||||
digest: contentDigest,
|
digest: contentDigest,
|
||||||
exif: exifData,
|
exif: exifData,
|
||||||
toneAnalysis,
|
toneAnalysis,
|
||||||
|
location: existingItem?.location ?? null,
|
||||||
// Video source (Motion Photo or Live Photo)
|
// Video source (Motion Photo or Live Photo)
|
||||||
video:
|
video:
|
||||||
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
|
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface PhotoProcessingLoggers {
|
|||||||
blurhash: CompatibleLoggerAdapter
|
blurhash: CompatibleLoggerAdapter
|
||||||
exif: CompatibleLoggerAdapter
|
exif: CompatibleLoggerAdapter
|
||||||
tone: CompatibleLoggerAdapter
|
tone: CompatibleLoggerAdapter
|
||||||
|
location: CompatibleLoggerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +116,7 @@ export function createPhotoProcessingLoggers(workerId: number, baseLogger: Logge
|
|||||||
blurhash: new CompatibleLoggerAdapter(workerLogger.withTag('BLURHASH')),
|
blurhash: new CompatibleLoggerAdapter(workerLogger.withTag('BLURHASH')),
|
||||||
exif: new CompatibleLoggerAdapter(workerLogger.withTag('EXIF')),
|
exif: new CompatibleLoggerAdapter(workerLogger.withTag('EXIF')),
|
||||||
tone: new CompatibleLoggerAdapter(workerLogger.withTag('TONE')),
|
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'
|
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'
|
export type ToneType = 'low-key' | 'high-key' | 'normal' | 'high-contrast'
|
||||||
|
|
||||||
@@ -60,6 +69,7 @@ export interface PhotoManifestItem extends PhotoInfo {
|
|||||||
digest?: string
|
digest?: string
|
||||||
exif: PickedExif | null
|
exif: PickedExif | null
|
||||||
toneAnalysis: ToneAnalysis | null // 影调分析结果
|
toneAnalysis: ToneAnalysis | null // 影调分析结果
|
||||||
|
location: LocationInfo | null // 地理位置信息(反向地理编码)
|
||||||
isHDR?: boolean
|
isHDR?: boolean
|
||||||
// Video source (Live Photo or Motion Photo)
|
// Video source (Live Photo or Motion Photo)
|
||||||
video?: VideoSource
|
video?: VideoSource
|
||||||
|
|||||||
Reference in New Issue
Block a user