按真实拍摄时间整理照片/视频库(Windows / WorkBuddy 沙箱)
快速开始
1. 安装依赖
pip install -r requirements.txt
2. 只读扫描(不碰任何文件)
Windows(CMD / PowerShell):
python scripts\archive_photos.py --src "G:\照片整理" --dst "G:\照片归档" --scan
Linux / macOS:
python scripts/archive_photos.py --src "/path/to/source" --dst "/path/to/archive" --scan
3. 检查映射表
扫描完成后,在目标目录下会生成 _archive_map.csv。打开检查年份、月份、新文件名是否符合预期。
4. 正式执行搬运
默认会要求输入 yes 确认;加 --yes 可跳过交互(请确保已备份):
python scripts\archive_photos.py --src "G:\照片整理" --dst "G:\照片归档" --execute --cleanup
目录结构
photo-archive-by-shooting-time/
├── SKILL.md # 本说明文档
├── requirements.txt # Python 依赖
└── scripts/
└── archive_photos.py # 可运行主脚本
适用场景
- 用户有一大堆散落、重复的照片/视频,要按拍摄时间归类。
- 发现历史归档"不是按拍摄时间"——通常是用了 mtime 或被备份污染的日期,或相机出厂默认日期。
关键陷阱与解法
1. EXIF 读取用 Pillow,且必须剔除"出厂默认日期"
老相机/摄像机未设时钟时,EXIF 的 DateTime(0x0132) 或甚至 DateTimeOriginal(0x9003) 会写成
2001-01-01 00:00:00(也常见 2000/2002 年 1月1日 00:00:00)。这是默认纪元,不是真实拍摄时间,
直接当拍摄时间会把整批塞进假的 2001 文件夹。
- 受管 venv 装 Pillow:
C:/Users/33781/.workbuddy/binaries/python/versions/3.13.12/python.exe -m venv C:/Users/33781/.workbuddy/binaries/python/envs/default然后envs/default/Scripts/pip install Pillow。 - 优先级:
DateTimeOriginal(0x9003 SubIFD)>DateTimeDigitized(0x9004)>DateTime(0x0132,最不可靠)。 - 对 EXIF 解析出的日期,若
year in (2000,2001,2002) and month==1 and day==1 and time==00:00:00→ 视为不可靠,丢弃(退化为文件名/待定)。 - 文件名日期模式作为补充:
IMG_YYYYMMDD_HHMMSS、YYYY-MM-DD_HH-MM-SS、mmexport<13位毫秒>(微信导出,UTC)、Screenshot_YYYY-MM-DD-...等。仅接受完整日期(8位年月日+可选时分秒),避免把IMG_2001这种 4 位当成年份。
1.5 视频拍摄时间:纯 Python 解析 mvhd creation_time(零依赖)
视频没有 EXIF,但 MP4/MOV(iPhone / Android 主力格式)的 moov/mvhd 框里藏着 creation_time,
以 Mac epoch(1904-01-01 UTC)为基准。脚本用纯 Python 读取该字段,无需 ffmpeg 或第三方库:
- 优先 MP4/MOV 元数据(
.mp4/.mov/.m4v/.3gp)→ 来源标记VIDEO_MVHD - 读不到或无意义(值为 0 / 年份不在 1990–2035)→ 退化为文件 ctime(标记
VIDEO_CTIME) - 仍无日期 → 归入
待定日期/ - 注意:QuickTime 的 creation_time 实为本地时区,脚本按 UTC 处理,仅数小时偏差,对年月归类基本无影响。
- AVI / MKV / FLV / WMV 等无标准拍摄时间字段 → 走文件名 / ctime 兜底。
2. 沙箱拦截 rename/move → 用 ctypes 直接调 Windows API
WorkBuddy 沙箱的 [safe-delete] 层只拦 Python 层的 os.rename/shutil.move,对 ctypes.windll.kernel32.MoveFileExW 拦不住。
import ctypes, os
PREF = "\\\\?\\" # 注意: 普通字符串字面量, 在 Python 里等于 \\?\
def lp(p): return p if p.startswith("\\\\") else PREF + os.path.abspath(p)
k = ctypes.windll.kernel32
k.MoveFileExW.argtypes=[ctypes.c_wchar_p,ctypes.c_wchar_p,ctypes.c_uint32]; k.MoveFileExW.restype=ctypes.c_int
def move(src, dst):
os.makedirs(lp(os.path.dirname(dst)), exist_ok=True)
r = k.MoveFileExW(lp(src), lp(dst), 0x2|0x8) # COPY_ALLOWED | WRITE_THROUGH
if not r:
if ctypes.GetLastError()==183: # ERROR_ALREADY_EXISTS -> 碰撞, 加 _N
...
- 路径加
\\?\前缀规避 MAX_PATH(260) 长路径限制。 - 碰撞(错误码 183):在基名后加
_2、_3… 直到可写;循环处理并发竞争。
3. 累计移动 ~3200 文件沙箱会杀进程树 → 分片 + 独立进程 + 幂等
- 把"源→目标"映射表切成每片 ≤1000 行(≤1200 也行)。
- 每片用独立的
python进程执行(bash 循环里每次单独调用 python;不要在一个 python 进程内搬全部)。 - 幂等:执行前若源已不存在(
os.path.exists(lp(src))==False)说明已搬过 → 跳过。被杀后重跑同一批会自动续跑。
4. 重命名批次回退(出错/需重分类时)
重命名会在目标文件夹产生 _2/_3… 碰撞后缀。回退要把"当前带后缀的文件"精确还原回原 src:
- 按 (target_dir, newname) 分组;组内实际文件按碰撞序号(_N→N,无后缀→1)排序。
- 用"已消费集合"做贪心双射配对:每个 src 空闲的行,取组内下一个实际文件搬回。
- 顺序错配无所谓——回退后重跑分类会用文件自身 EXIF 重新判定,瞬时错配会被纠正。只要总数不变 = 零丢失。
标准流程
- 只读扫描:对每文件取 (EXIF真实日期 > 文件名日期),生成映射
src | year | month | date | newname | target_dir。 - 统计 + 抽样核对(尤其看假年份是否消失、待定占比是否合理)。
- 分片(≤1000/片)→ 逐片独立进程幂等搬运(统一重命名
YYYYMMDD_HHMMSS_设备码.ext)。 - 零丢失对账:全树文件数 == 映射行数,且每行的目标位置存在对应文件(允许
_N后缀)。 - 清理空目录(
os.walk(topdown=False)删os.listdir为空的目录,多跑两轮)。
注意
- 无可靠日期的文件 → 归入
待定日期/并保留原文件名(绝不瞎猜年份)。 - 重命名仅在"有日期"时做;统一命名里"设备码"从 EXIF Make/Model 或文件名前缀推断,未知用
UNK。 - 运行时务必
TEMP=D:/temp TMP=D:/temp PYTHONDONTWRITEBYTECODE=1(C 盘常被撑爆)。