Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ S3_SECRET_ACCESS_KEY=

PG_CONNECTION_STRING=
GIT_TOKEN=

NOMINATIM_ACCEPT_LANGUAGE=en
VITE_SHOW_DETAILED_LOCATION=true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ builder.config.json
apps/web/assets-git
apps/web/public/thumbnails
apps/web/src/data/photos-manifest.json
apps/web/src/data/.geocode-cache.json
.vercel
photos
*/*/.next
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import './PhotoViewer.css'

import type { PhotoManifestItem, PickedExif } from '@afilmory/builder'
import { siteConfig } from '@config'
import { isNil } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import { m } from 'motion/react'
Expand Down Expand Up @@ -34,7 +35,7 @@ export const ExifPanel: FC<{

onClose?: () => void
}> = ({ currentPhoto, exifData, onClose }) => {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const isMobile = useMobile()
const formattedExifData = formatExifData(exifData)
const isExiftoolLoaded = useAtomValue(isExiftoolLoadedAtom)
Expand All @@ -45,6 +46,35 @@ export const ExifPanel: FC<{
const decimalLatitude = gpsData?.latitude || null
const decimalLongitude = gpsData?.longitude || null

const locationDisplay = useMemo(() => {
const loc = currentPhoto.location
if (!loc) return null

// Allow showing detailed location directly from Nominatim's display_name
const showDetailed = siteConfig.ui?.showDetailedLocation || false
if (showDetailed && loc.displayName) return loc.displayName

const city = loc.city || null
const province = loc.province || null
const country = loc.country || null

// Order rule: default "city, province, country"; for zh/ja languages use "country province city"
const lang = (navigator?.language || 'en').toLowerCase()
const activeLang = (i18n?.language || lang).toLowerCase()
const isEastAsiaOrder =
activeLang.startsWith('zh') ||
activeLang.startsWith('ja') ||
activeLang === 'jp'

const parts = isEastAsiaOrder
? [country, province, city].filter(Boolean)
: [city, province, country].filter(Boolean)

if (parts.length === 0) return null
// For East Asian order, use spaces; otherwise commas
return isEastAsiaOrder ? parts.join(' ') : parts.join(', ')
}, [currentPhoto])

// 使用通用的图片格式提取函数
const imageFormat = getImageFormat(
currentPhoto.originalUrl || currentPhoto.s3Key || '',
Expand Down Expand Up @@ -578,6 +608,9 @@ export const ExifPanel: FC<{
value={`${formattedExifData.gps.altitude}m`}
/>
)}
{locationDisplay && (
<Row label={'Location'} value={locationDisplay} />
)}

{/* Maplibre MiniMap */}
{decimalLatitude !== null && decimalLongitude !== null && (
Expand Down
3 changes: 3 additions & 0 deletions builder.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"prefix": "photos/",
"customDomain": "https://cdn.example.com",
"endpoint": "https://s3.amazonaws.com"
},
"geocoding": {
"acceptLanguage": "en"
}
}
10 changes: 10 additions & 0 deletions builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export interface BuilderConfig {
logFilePath?: string
}

// 地理编码配置
geocoding: {
// Nominatim API 接受的语言
acceptLanguage: string
}

// 性能优化配置
performance: {
// Worker 池配置
Expand Down Expand Up @@ -103,6 +109,10 @@ export const defaultBuilderConfig: BuilderConfig = {
digestSuffixLength: 0,
},

geocoding: {
acceptLanguage: 'en',
},

logging: {
verbose: false,
level: 'info',
Expand Down
5 changes: 4 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@
"map": [
"maplibre"
],
"mapStyle": "builtin"
"mapStyle": "builtin",
"ui": {
"showDetailedLocation": false
}
}
36 changes: 36 additions & 0 deletions packages/builder/src/lib/geo/coords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { PickedExif } from '../../types/photo.js'

export function convertExifGPSToDecimal(
exif: PickedExif | null,
): { latitude: number; longitude: number } | null {
if (!exif) return null
let latitude: number | null = null
let longitude: number | null = null

if (typeof exif.GPSLatitude === 'number') {
latitude = exif.GPSLatitude
} else if (exif.GPSLatitude) {
const num = Number(exif.GPSLatitude)
latitude = Number.isFinite(num) ? num : null
}

if (typeof exif.GPSLongitude === 'number') {
longitude = exif.GPSLongitude
} else if (exif.GPSLongitude) {
const num = Number(exif.GPSLongitude)
longitude = Number.isFinite(num) ? num : null
}

if (latitude === null || longitude === null) return null

const latSouth =
exif.GPSLatitudeRef === 'S' || exif.GPSLatitudeRef === 'South'
const lonWest =
exif.GPSLongitudeRef === 'W' || exif.GPSLongitudeRef === 'West'

const lat = latSouth ? -Math.abs(latitude) : Math.abs(latitude)
const lon = lonWest ? -Math.abs(longitude) : Math.abs(longitude)

if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
return { latitude: lat, longitude: lon }
}
160 changes: 160 additions & 0 deletions packages/builder/src/lib/geocoding/nominatim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import fs from 'node:fs/promises'
import path from 'node:path'

import { builderConfig } from '@builder'

import { workdir } from '../../path.js'

export type ReverseGeocodeResult = {
city: string | null
province: string | null
country: string | null
displayName?: string | null
}

type NominatimResponse = {
display_name?: string
address?: Record<string, string>
}

const CACHE_FILE = path.join(workdir, 'src/data/.geocode-cache.json')
const CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000 // 1 year

type CacheEntry = { v: ReverseGeocodeResult; t: number }
type CacheData = Record<string, CacheEntry>

let cache: CacheData | null = null

async function loadCache(): Promise<CacheData> {
if (cache) return cache
try {
const raw = await fs.readFile(CACHE_FILE, 'utf-8')
const json = JSON.parse(raw) as CacheData
cache = json || {}
} catch {
cache = {}
}
return cache!
}

async function saveCache(): Promise<void> {
if (!cache) return
try {
await fs.mkdir(path.dirname(CACHE_FILE), { recursive: true })
await fs.writeFile(CACHE_FILE, JSON.stringify(cache), 'utf-8')
} catch {
// ignore
}
}

const roundCoord = (n: number, decimals = 3) => Number(n.toFixed(decimals))
const makeKey = (lat: number, lon: number, lang: string) =>
`${roundCoord(lat)},${roundCoord(lon)}@${lang}`

let queue: Promise<unknown> = Promise.resolve()
let lastAt = 0
const schedule = <T>(fn: () => Promise<T>): Promise<T> => {
const res = queue.then(async () => {
const now = Date.now()
const wait = Math.max(0, 1000 - (now - lastAt))
if (wait > 0) {
await new Promise((r) => setTimeout(r, wait))
}
lastAt = Date.now()
return fn()
}) as Promise<T>
queue = res.then(
() => {},
() => {},
)
return res
}

const extractCityProvinceCountry = (
data: NominatimResponse,
): ReverseGeocodeResult => {
const a = data.address || {}
const city =
a.city ||
a.town ||
a.village ||
a.municipality ||
a.city_district ||
a.suburb ||
a.county ||
a.hamlet ||
null

const province =
a.province ||
a.state ||
a.region ||
(a.state_district as string | undefined) ||
null

const country = a.country || null

return { city: city || null, province, country }
}

const inflight = new Map<string, Promise<ReverseGeocodeResult>>()

export async function reverseGeocode(
lat: number,
lon: number,
): Promise<ReverseGeocodeResult | null> {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
await loadCache()
const {acceptLanguage} = builderConfig.geocoding
const key = makeKey(lat, lon, acceptLanguage)

// cache hit
const hit = cache![key]
if (hit && Date.now() - hit.t < CACHE_TTL_MS) {
return hit.v
}

const existing = inflight.get(key)
if (existing) return existing

const p = schedule(async () => {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(
lat,
)}&lon=${encodeURIComponent(lon)}&format=json&accept-language=${encodeURIComponent(
acceptLanguage,
)}`
try {
const res = await fetch(url, {
headers: {
Accept: 'application/json',
'User-Agent': 'afilmory-builder/1.0',
},
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as NominatimResponse
const result = {
...extractCityProvinceCountry(data),
displayName: data.display_name || null,
}
cache![key] = { v: result, t: Date.now() }
await saveCache()
return result
} catch {
// store negative cache to avoid retry storms
const result: ReverseGeocodeResult = {
city: null,
province: null,
country: null,
displayName: null,
}
cache![key] = { v: result, t: Date.now() }
await saveCache()
return result
} finally {
inflight.delete(key)
}
})

inflight.set(key, p)
return p
}
43 changes: 43 additions & 0 deletions packages/builder/src/photo/data-processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
generateThumbnailAndBlurhash,
thumbnailExists,
} from '../image/thumbnail.js'
import { convertExifGPSToDecimal } from '../lib/geo/coords.js'
import { reverseGeocode } from '../lib/geocoding/nominatim.js'
import { decompressUint8Array } from '../lib/u8array.js'
import { workdir } from '../path.js'
import type {
Expand Down Expand Up @@ -135,3 +137,44 @@ export async function processToneAnalysis(
// 计算新的影调分析
return await calculateHistogramAndAnalyzeTone(sharpInstance)
}

/**
* 处理位置信息(反向地理编码)
* 复用现有数据,或根据 EXIF GPS 解析出城市/省份/国家
*/
export async function processLocation(
exifData: PickedExif | null,
photoKey: string,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<PhotoManifestItem['location']> {
const loggers = getGlobalLoggers()

// 复用现有
if (
!options.isForceMode &&
!options.isForceManifest &&
existingItem?.location
) {
const photoId = path.basename(photoKey, path.extname(photoKey))
loggers.main.info(`复用现有位置信息:${photoId}`)
return existingItem.location
}

const coords = convertExifGPSToDecimal(exifData)
if (!coords) return null

try {
const geo = await reverseGeocode(coords.latitude, coords.longitude)
if (!geo) return null
return {
city: geo.city,
province: geo.province,
country: geo.country,
displayName: geo.displayName || null,
}
} catch (e) {
loggers.main.warn('反向地理编码失败,已跳过:', e)
return null
}
}
7 changes: 7 additions & 0 deletions packages/builder/src/photo/image-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { PhotoManifestItem } from '../types/photo.js'
import { shouldProcessPhoto } from './cache-manager.js'
import {
processExifData,
processLocation,
processThumbnailAndBlurhash,
processToneAnalysis,
} from './data-processors.js'
Expand Down Expand Up @@ -228,6 +229,12 @@ export async function executePhotoProcessingPipeline(
size: obj.Size || 0,
exif: exifData,
toneAnalysis,
location: await processLocation(
exifData,
photoKey,
existingItem,
options,
),
// Live Photo 相关字段
isLivePhoto: livePhotoResult.isLivePhoto,
livePhotoVideoUrl: livePhotoResult.livePhotoVideoUrl,
Expand Down
Loading