跳到主要内容

旅游照片元数据工作流

这篇文档是这个仓库里旅游相册页面的专用规范。目标只有两个:

  • 让旅游照片在灯箱里稳定展示低调的一行元数据
  • 让以后“给 AI 一份全是图片链接的 Markdown,然后让它补完整篇旅行相册”这件事可以重复执行

如果你后续继续往 docs/Travel 增加新的旅行页面,建议以这篇文档为准。

先理解当前实现

当前仓库里旅游照片元数据的链路是:

  1. 你先准备带有效 EXIF 的照片
  2. 照片被转成适合网页的 JPEG 并上传到 https://picture.nevergpdzy.cn/ 下的稳定路径
  3. docs/Travel/*.mdx 里用 createTravelPhotos([...]) 按域名相对路径引用这些照片
  4. 运行 npm run generate:travel-metadata
  5. 脚本 scripts/generate-travel-photo-metadata.mjs 会按这些相对路径下载 JPEG,读取 EXIF,并生成 src/data/travelPhotoMetadata.generated.json
  6. src/components/TravelGallery/index.js 在灯箱中读取这份生成文件,把元数据显示在照片下方

当前灯箱实际使用的是这 4 个字段:

字段来源是否显示
locationLabel来自旅行文章标题,不来自 GPS显示
capturedAtEXIF DateTimeOriginal,没有就回退到 DateTime显示
deviceEXIF Make + Model显示
lensEXIF LensModel,iPhone 会被归一化成短标签显示

另外还会生成 hasMetadata,用于标记这一张图是否至少提取到了部分可用元数据。

需要记住的几个关键事实

1. 页面显示的地点不是从 GPS 来的

当前页面展示的地点文字来自文章 front matter 里的 title,不是从照片 GPS 直接反推出来的。

例如:

---
title: 武汉 Wuhan
---

最终灯箱里会显示 武汉

再例如:

---
title: 黄龙与九寨沟 Jiuzhai Valley
---

最终灯箱里会显示 黄龙与九寨沟

所以如果你想让地点显示正确,最重要的是把旅行文章标题写对,而不是指望照片 GPS 决定前端文案。

2. 当前元数据脚本只处理 JPEG,标准输入是域名相对路径

当前脚本识别的是 .jpg / .jpeg 图片引用。标准写法是域名相对路径,例如:

img_for_Typora/IMG_5867_WuHan.jpg
JingXi/IMG_5867_WuHan.jpg
Travel/JingXi/IMG_5867_WuHan.jpg

也就是说:

  • 旅游页面里应该引用 JPEG 文件
  • 新文章应该优先写域名相对路径,而不是只写文件名
  • 如果你的原图是 HEIC / HEIF,要先转成 JPEG

为了兼容旧文章,组件仍然能接受只写文件名的写法,但它会自动把:

IMG_5867_WuHan.jpg

解释成:

img_for_Typora/IMG_5867_WuHan.jpg

这只是兼容规则,不是新的推荐规范。

HEIF 转 JPEG 的具体命令可以继续参考:

3. iPhone 镜头名称会被压缩成短标签

当前前端和生成脚本都对 iPhone 镜头做了短标签归一化,最终只保留下面这几种:

  • 超广角
  • 主摄
  • 长焦
  • 前置

如果不是 iPhone,或者镜头型号无法命中这些规则,就会保留原始镜头字段。

用户上传前的准备规范

如果你希望 AI 后续能把照片元数据正确带出来,上传前请先满足下面这些前提。

1. 源照片尽量保留“原片链路”

优先级建议是:

  1. 手机或相机原片
  2. AirDrop / 数据线导出 / 相册“导出未修改的原片”
  3. 明确声明保留 EXIF 的批量转换
  4. 最后才是任何社交软件、聊天软件或网页中转

不推荐直接拿下面这些来源当最终发布源:

  • 微信、QQ、微博、小红书等社交平台里转发过的图片
  • 聊天窗口里“另存为”的图片
  • 已经被网站二次压缩过的下载图
  • 在线压缩工具输出图

这些路径最容易把 DateTimeOriginalModelLensModel、GPS 等信息直接抹掉。

2. 尽量一篇旅行文章只对应一个地点标题

因为 locationLabel 由文章标题继承,所以一篇 docs/Travel/*.mdx 最好只描述一个主地点。否则:

  • 页面标题会变得模糊
  • 灯箱里每一张图都会显示同一个地点名
  • 即使某些照片自带 GPS,也不会直接覆盖这里的显示结果

3. 文件名尽量稳定、可读、一次定好

当前仓库里旅游照片的末级文件名大多类似:

IMG_5867_WuHan.jpg
IMG_7107_HongKong.jpg
IMG_0248_JiuzhaiValley.jpg

推荐继续保持这种风格:

  • 保留相机原始序号
  • 在后缀里带上地点英文标识
  • 上传之后不要再反复重命名

一旦文件名变了,你需要同时更新:

  • 远端图片地址
  • docs/Travel/*.mdx 中的引用
  • 重新生成元数据清单

如果你用了多级目录,那路径本身也要稳定。例如:

img_for_Typora/IMG_5867_WuHan.jpg
Travel/JingXi/IMG_5867_WuHan.jpg

4. 图片链接最好已经是稳定的公网地址

如果你下次给 AI 的是“全是图片链接的 Markdown”,最好这些链接已经满足:

  • 能直接在浏览器打开
  • 是稳定地址,不带临时鉴权参数
  • 最终落在 https://picture.nevergpdzy.cn/ 这个域名下
  • 文件扩展名是 .jpg.jpeg

如果你给的是临时链接、短链接、重定向链、网盘鉴权链接,AI 可以改文档,但元数据脚本不一定能稳定拉取到最终文件。

上传前如何检查照片有没有被压缩掉有效元数据

这里的“有效元数据”,对当前仓库来说,至少指下面这几项里有一部分仍然存在:

  • 拍摄时间 DateTimeOriginal
  • 设备型号 Model
  • 镜头型号 LensModel
  • 可选的 GPS 信息

如果这些字段全没了,灯箱里通常就只会剩地点,或者完全没有可显示的摄影信息。

先看最直观的异常信号

如果一张图出现下面这些现象,通常就要怀疑它已经不是原始带 EXIF 的版本了:

  • 文件体积明显异常小,只有几百 KB,但本来应该是手机原图
  • 分辨率被压到固定长边,比如 1280、1600、2048 一类
  • 拍摄时间、设备型号、镜头型号全部缺失
  • 同一组照片里只有极少数照片还带设备信息
  • 明明当时开了定位,但所有照片都完全没有 GPS

如果你拍的是 iPhone 原片,而你手上的 JPEG 普遍已经变成“体积明显变小 + 尺寸被统一压缩 + EXIF 基本空了”,基本可以认为这是中间平台处理过的版本。

最推荐的检查方式:exiftool

如果你机器里已经装了 exiftool,优先用它。它最适合检查“元数据还剩多少”。

检查单张图:

exiftool -DateTimeOriginal -Model -LensModel -GPSLatitude -GPSLongitude -ImageWidth -ImageHeight IMG_0001.jpg

如果想顺带看是否有后期软件痕迹,也可以再加一个 Software

exiftool -DateTimeOriginal -Model -LensModel -GPSLatitude -GPSLongitude -Software IMG_0001.jpg

理想状态一般是:

  • DateTimeOriginal 有值
  • Model 有值
  • LensModel 有值
  • 如果当时开启了定位,GPSLatitude / GPSLongitude 也有值

如果只剩像素尺寸,而拍摄时间、设备、镜头都没了,就不要拿这份图去做旅游元数据展示。

Windows 无额外工具时的检查方式

如果你没有 exiftool,可以直接用 PowerShell 读 EXIF 的几个关键字段:

Add-Type -AssemblyName System.Drawing

function Read-ExifAscii($image, $id) {
$prop = $image.PropertyItems | Where-Object Id -eq $id | Select-Object -First 1
if (-not $prop) {
return $null
}

return ([System.Text.Encoding]::ASCII.GetString($prop.Value)).Trim([char]0)
}

$path = (Resolve-Path .\IMG_0001.jpg).Path
$image = [System.Drawing.Image]::FromFile($path)

[pscustomobject]@{
DateTimeOriginal = Read-ExifAscii $image 0x9003
Model = Read-ExifAscii $image 0x0110
LensModel = Read-ExifAscii $image 0xA434
Width = $image.Width
Height = $image.Height
}

$image.Dispose()

如何判断结果是否合格:

  • DateTimeOriginal 有值,说明拍摄时间大概率还在
  • Model 有值,说明设备信息还在
  • LensModel 有值,说明镜头信息大概率还在
  • 三项都空,基本可以判定这份 JPEG 的关键 EXIF 已经被剥离了

macOS 无额外工具时的检查方式

macOS 可以先用内置的 mdls 做一个快速筛查:

mdls -name kMDItemContentCreationDate \
-name kMDItemAcquisitionMake \
-name kMDItemAcquisitionModel \
-name kMDItemPixelWidth \
-name kMDItemPixelHeight \
IMG_0001.jpg

如果这里已经完全看不到拍摄时间和设备型号,那这张图大概率也不适合作为旅游元数据源。

需要注意的是:

  • mdls 更适合快速筛查
  • 镜头和 GPS 之类更完整的 EXIF,还是 exiftool 更稳

图形界面也可以做第一轮检查

如果你不想先跑命令,也可以先用系统界面看一眼:

  • Windows:右键图片,打开“属性 -> 详细信息”
  • macOS:选中文件,按空格预览或“显示简介”

如果这里已经完全看不到拍摄日期、设备、尺寸等信息,就没必要再指望仓库脚本能从这张图里提取到完整数据。

这个仓库里推荐的完整操作流程

下面这套流程,适合以后继续增加新的旅行页面。

第一步:准备照片

要求:

  • 尽量保留 EXIF
  • 最终使用 JPEG
  • 文件名和路径稳定
  • 图片已经上传到 https://picture.nevergpdzy.cn/ 下的稳定地址

如果原始格式是 HEIC,请先参考 苹果 HEIF 照片网站展示指南 转成 JPEG,再做后续步骤。

第二步:准备给 AI 的 Markdown

如果你希望以后 AI 能稳定接手,最好把源 Markdown 写成“标题 + 若干小节 + 每节若干图片链接”的结构。例如:

# 武汉

## 抵达

![](https://picture.nevergpdzy.cn/img_for_Typora/IMG_5867_WuHan.jpg)
![](https://picture.nevergpdzy.cn/img_for_Typora/IMG_6001_WuHan.jpg)

## 街头

![](https://picture.nevergpdzy.cn/img_for_Typora/IMG_6069_WuHan.jpg)
![](https://picture.nevergpdzy.cn/img_for_Typora/IMG_6076_WuHan.jpg)

这样 AI 可以直接按照小节来拆分 createTravelPhotos([...]) 数组。

如果你给的是一整坨没有标题、只有图片 URL 的 Markdown,AI 也能处理,但它就只能靠图片顺序、路径和文件名猜测分组,结果会不如显式分段稳定。

第三步:AI 把 Markdown 转成仓库里的旅行页面

AI 在这个仓库里接手时,应按下面的规则执行。

1. 页面落点

新文章应放在:

  • docs/Travel/<slug>.mdx

2. front matter

必须至少有:

---
title: 武汉 Wuhan
description: 这里写这一篇旅行相册的简短摘要。
hide_table_of_contents: true
---

其中:

  • title 会影响灯箱里显示的地点名
  • 如果你想让地点显示成中文,标题里必须把中文地点放在前面

3. 引入组件

页面顶部统一使用:

import TravelGallery, {createTravelPhotos} from '@site/src/components/TravelGallery';

4. 图片数组写法

图片数组应该写域名相对路径,不要直接把整条 URL 写进 createTravelPhotos([...])

export const arrivalPhotos = createTravelPhotos([
'img_for_Typora/IMG_5867_WuHan.jpg',
'img_for_Typora/IMG_6001_WuHan.jpg',
]);

原因是:

  • 这种写法可以稳定支持多目录
  • 元数据脚本当前也是围绕“相对路径 -> 下载 -> 解析 EXIF”这条链路工作的
  • 如果不同目录里有同名图片,相对路径可以避免冲突

5. 分组规则

AI 处理用户上传的 Markdown 时,优先按这个顺序分组:

  1. 按源 Markdown 的二级标题或三级标题分组
  2. 如果没有标题,按明显的场景段落分组
  3. 如果仍然没有信息,就按原始顺序拆成 2 到 4 组

6. 保留图片顺序

同一组里的图片顺序,默认不要调整。因为:

  • 拍摄时间在多数情况下本来就是顺序信息
  • 用户通常已经按浏览叙事排过一次

7. 文案与图片的关系

图片下面的元数据会自动显示,所以 AI 不需要把拍摄时间、设备、镜头手工写进正文。

正文更适合承担的是:

  • 旅行叙述
  • 场景切换
  • 各组照片之间的节奏说明

8. 推荐的最小 MDX 模板

---
title: 武汉 Wuhan
description: 以相册的方式记录一次在武汉放慢脚步的短暂停留。
hide_table_of_contents: true
---

import TravelGallery, {createTravelPhotos} from '@site/src/components/TravelGallery';

export const arrivalPhotos = createTravelPhotos([
'img_for_Typora/IMG_5867_WuHan.jpg',
'img_for_Typora/IMG_6001_WuHan.jpg',
]);

export const streetPhotos = createTravelPhotos([
'img_for_Typora/IMG_6069_WuHan.jpg',
'img_for_Typora/IMG_6076_WuHan.jpg',
]);

这里写开场文字。

<TravelGallery images={arrivalPhotos} />

## 把脚步放慢一点

这里写第二段文字。

<TravelGallery images={streetPhotos} />

第四步:生成元数据清单

页面内容完成后,运行:

npm run generate:travel-metadata

这个命令会:

  • 扫描 docs/Travel/*.mdx
  • 提取所有 createTravelPhotos([...]) 里的 JPEG 相对路径
  • 下载对应图片
  • 读取 EXIF
  • 生成 src/data/travelPhotoMetadata.generated.json

如果命令成功,终端最后会看到类似:

Wrote N travel photo records to .../src/data/travelPhotoMetadata.generated.json

第五步:构建验证

然后运行:

npm run build

至少检查下面几件事:

  • 页面能成功构建
  • 旅游页面能正常打开
  • 灯箱能正常打开和切换
  • 照片下方的一行元数据能正常显示
  • 宽屏和窄屏下,日期与“设备 + 镜头”换行逻辑正常

AI 接手“全是图片链接的 Markdown”时的规范动作

以后如果你上传一份 Markdown 给 AI,希望它直接补完这个仓库里的旅游页面,AI 应该按下面的顺序执行。

1. 先提取图片相对路径

如果源内容是:

![](https://picture.nevergpdzy.cn/img_for_Typora/IMG_5867_WuHan.jpg)

AI 最终在仓库里应该写成:

'img_for_Typora/IMG_5867_WuHan.jpg'

不是保留整条 URL。

如果源内容是:

![](https://picture.nevergpdzy.cn/Travel/JingXi/IMG_5867_WuHan.jpg)

AI 最终应该写成:

'Travel/JingXi/IMG_5867_WuHan.jpg'

2. 保留原 Markdown 的章节结构

如果源 Markdown 已经有:

  • 标题
  • 小标题
  • 段落

AI 应尽量保留这些结构,并把每一节对应成一个 createTravelPhotos([...]) 数组和一个 <TravelGallery />

3. 用标题决定地点文案

如果用户说这篇是“武汉”,那 front matter 标题应该写成类似:

title: 武汉 Wuhan

不要只写英文,否则灯箱里显示的地点文案也会跟着变成英文。

4. 不要手工伪造 EXIF

AI 可以整理页面结构,但不应该在页面里手工伪造如下字段:

  • 拍摄时间
  • 设备型号
  • 镜头型号
  • GPS

这些应该来自照片本身。如果照片元数据已经丢了,就应该接受灯箱里少显示,或者直接提醒用户这批图不适合做摄影元数据展示。

5. 生成后一定跑两条命令

AI 在改完文件之后,应该至少跑:

npm run generate:travel-metadata
npm run build

如果没有跑这两条命令,就不能算流程完成。

推荐给未来 AI 的直接指令模板

以后你可以直接把下面这段要求连同 Markdown 一起给 AI:

请把我上传的 Markdown 转成这个仓库里的旅游相册页面,要求:
1. 新文件放到 docs/Travel 下,使用 MDX。
2. front matter 的 title 先写中文地点,再写英文地点。
3. 保留原 Markdown 的章节结构,并按章节生成 createTravelPhotos([...])。
4. createTravelPhotos 里只保留 `https://picture.nevergpdzy.cn/` 后面的相对路径,不要保留完整 URL。
5. 使用 TravelGallery 渲染每一组图片。
6. 不要手工编造拍摄时间、设备、镜头、GPS。
7. 改完后运行 npm run generate:travel-metadata 和 npm run build。
8. 如果发现照片没有有效 EXIF,请明确告诉我哪些图缺失元数据。

这段模板的目的,是让 AI 直接走当前仓库已经存在的实现,而不是临时发明另一套图片组件或另一份元数据结构。

验收清单

每次新增旅行页面后,建议至少核对下面这些点:

  • docs/Travel/<slug>.mdx 中所有图片都只写 https://picture.nevergpdzy.cn/ 后面的相对路径
  • 这些相对路径都能在远端域名下访问到
  • npm run generate:travel-metadata 成功执行
  • src/data/travelPhotoMetadata.generated.json 里能找到新增图片
  • 关键图片至少能看到 capturedAtdevicelens
  • 页面构建成功
  • 灯箱中的地点文案正确
  • 手机端和桌面端的灯箱布局都正常

常见故障与处理

1. 生成脚本成功了,但某些图片元数据全是空

常见原因:

  • 图片本身已经被压缩或清洗过 EXIF
  • 下载到的并不是原始 JPEG
  • 这张图本来就没有镜头或时间字段

优先处理顺序:

  1. 先用 exiftool 或系统命令确认这张 JPEG 本地是否还有元数据
  2. 再确认远端链接是不是同一份文件
  3. 如果远端不是原图,重新上传保留 EXIF 的版本

2. 页面地点显示不对

优先检查文章 front matter 的 title,不要先怀疑 GPS。

因为当前前端显示的地点来自文章标题,不来自照片 GPS。

3. 同一张图被多个旅行页面复用后报错

当前脚本会检查同一路径是否被多个旅行页面赋予了不同的地点标签。如果同一张图在两篇文章里出现,而且两篇文章标题不一致,脚本可能报:

Conflicting location labels for ...

这种情况要么:

  • 不要跨页面复用同一张图
  • 要么确保它们继承到的是同一个地点标题

4. 用户给的是 HEIC、PNG 或带查询参数的奇怪链接

当前旅游元数据链路最稳的是:

  • 公网 JPEG
  • 路径稳定
  • 能直接下载

如果不是这个形态,先把素材整理成符合要求的 JPEG,再进入旅行页面流程。

5. PowerShell 里看文档或 JSON 出现中文乱码

这个仓库在 Windows PowerShell 里偶尔会出现中文显示异常,但文件本身通常还是正常的 UTF-8。

遇到这种情况:

  • 优先用编辑器直接打开文件确认
  • 或者用 node 读取 UTF-8 内容再看

不要因为终端显示乱码,就误判文档本身已经损坏。

相关文档