老妈旧手机空间不足。我买了台新的 vivo x200s,需要把旧机的照片搬过去。
老人家的照片主要有两个渠道:
- 自己手机拍的占绝大多数;自动同步到华为云空间
- 微信接收的
- 更早前的手机或者相机拍的,存在华为云空间中
考虑到老人因为手机空间不足已经删掉了一部分视频,因此华为云空间中的照片是最全的。于是我决定从华为那把所有照片视频下载下来。目标是最终都同步到 Google Photos。
从华为云空间下载
遇到的第一个麻烦是,华为云空间只有 Windows 客户端,没有 Mac 的。虽然有网页版,但一次只能下载 500 张,我要是操作完所有照片腱鞘炎一定不会放过我。好在我家里有 Mac Mini 也有 Windows 台式机,于是把台式机开了起来。
云空间的客户端上可以一次性下载所有照片视频,而且带宽给得非常足,可以跑满我家的千兆带宽,下载速度到 100MB/s。但第一个坑出现了。我把下载路径选在一个机械硬盘上,它的写入速度成了瓶颈,只能在 40MB/s 的速度。而且下载了 70G 后硬盘居然掉了(怀疑是过热),这个 D 盘直接从我的电脑中消失,下载也被中断了。
重启电脑后硬盘回来了。但华为这个垃圾云空间又开始搞事,刚才的下载任务不见了,我必须全选照片重新下载。选了同个下载路径后,没想到已经下载好的 70G 它不认了,也需要重新下载!而且还给我搞出一堆重复的文件来。比如 IMG_123456.jpg
, IMG_123456(1).jpg
,后者是因为重新下载时因为有重名而自动加的 (1)
后缀。妈呀你就不能算个 hash 再决定要不要重新下载吗。
郁闷的我换了策略,不再下载到机械硬盘了,而是把照片和视频分开下载到固态硬盘中。用了固态硬盘后下载速度起飞,到 100MB/s,重新下载前面 70G 也不是特别花时间。下载好后就开始解决第二个问题:压缩文件大小。
压缩照片大小
图片和视频的压缩是不一样的。图片领域有很多工具,可以在基本不影响效果的情况下去做压缩。MacOS 上可以用 ImageOptim (免费)、 PhotoBulk (收费),Windows 上可以用 RIOT 。它们的原理都是类似的,使用多个开源的压缩工具(如 jpegoptim, jpegtran)来做压缩,然后使用体积最小的结果。它们也都可以实现 in-place 替换,即将压缩结果替换掉原文件。因为是在 Windows 上操作,我这次使用的是 RIOT。压缩后效果显著,降了一半左右的空间。
另外要注意的是,这些软件往往有移除元信息(EXIF)的功能。建议把这些功能关掉。一方面备份照片并不需要担心隐私泄露(如果你不介意 Google 扫描这些);另一方面里面有拍摄地点等信息,上传到 Google Photos 后可以很方便地根据位置来筛选图片。
压缩视频大小
视频的压缩,我直接在 GitHub Copilot 上问了 GPT-5 怎么做好。它给的意见是:
- 视频封装成 H.265 格式
- 如果码率过高降低码率
然后它给我生成了一个脚本,放在文章末尾了。跑这个脚本的结果是直接砍了 70% 的体积:

元信息混乱
除了上面提到的图片元信息的保留,视频文件也存在元信息。我让 Copilot 生成的脚本中需要保留元信息,于是它给我加了 -map_metadata
参数,理论上应该是 OK 的。当我把处理完的视频传到 Google Photos 后,我惊奇地发现,里面多出一堆日期为今天的视频。这显然是不对的,这些视频没有一个是今天拍的。但也并不是全部视频文件都有这个问题。
做了一番研究后,我发现可以用 mdls
命令看视频文件的元信息:
# 日期正常的文件
> mdls IMG_0431.MOV
kMDItemFSContentChangeDate = 2022-07-03 09:37:17 +0000 # 22 年拍的,是对的
kMDItemFSCreationDate = 2025-09-09 12:57:05 +0000
# 日期不正常的文件
> mdls a66066972088aeaa5d08e2be3c022275.mp4
kMDItemFSContentChangeDate = 2025-09-09 13:25:13 +0000 # 并不是 25 年拍的
kMDItemFSCreationDate = 2025-09-09 13:25:13 +0000
Google Photos 取的应该是 kMDItemFSContentChangeDate
这个字段作为其拍摄日期。
再研究了一下,发现从微信 Mac 版中提取的视频,它的这个日期全都是错的,不是拍摄日期而是文件生成的日期。这又讲回到我是怎样同步微信中的视频的。我将手机微信中的聊天纪录一次性迁移到微信 Mac 版,然后就可以在这个路径捞到所有的视频文件:
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{wechat-id}/msg/video
但显然微信并没有把元信息保留下来。
如果是长时间有使用 Mac 微信,那么不存在这个问题,相关的视频应该在接收到它的当天就被下载下来,因此与拍摄时间的差异不会太大。但是像我这种一次性从手机导入的,时间就错得离谱,失去了参考价值。
那微信上的视频还要吗?
对于我这样有信息洁癖的人来说,微信中的视频时间跨度很长,让它们都挤在 Google Photos 上的同一天,是我无法接受的。这也会影响 Google 的算法对一些内容的生成,比如它会时不时给你推送小孩不同年龄的对比照片。如果时间线错乱影响到了这些能力就有点不必要。于是我先把这些视频从 Google Photos 上移除了。
分析微信上的视频来源
移除之后,我还是想分析一下这些视频的价值。这些视频有两类:
- 自己发给别人的,这些已经包含在上面云空间的内容中,无需重复备份
- 别人发来的
我试用了几个重复视频分析器,用来分析微信的视频与手机的视频有多少是重复的。试了这两个 Mac 软件:
- Gemini 2 :做 Clean My Mac 和 Setapp 的 Macpaw 公司出的,但实际效果不佳,只把文件完全一样的找出来了;而且收费不便宜
- Video simili duplicate cleaner :效果很好,找出了几百个重复;而且一次性买断,才 58 块钱
除去重复的视频后,还是剩下挺多别人发来的视频。
给这些视频一个近似的时间
这时我想到,这些视频在微信的目录中,是带有一个月份的。它的目录结构像这样:
Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{wx_id}/msg/video
|- 2025-09
|- 74f4726202c1a826ca02f25a3857d0ba.mp4
|- 2025-08
|- 5f167fdf4f2ea7c405e80a151bf8a3ef_raw.mp4
有了月份,这些视频就有一个近似的时间。我又让 Copilot 帮我写了段 Python 脚本,读取目录所表示的月份,作为拍摄日期写入视频文件中。这个时间虽然不是很精确,但是也足够了。这个脚本也附在下面。
至此,备份的任务已经完成。
压缩视频的脚本
脚本是在 MacOS 上运行的,需要先安装 ffmpeg:
#!/usr/bin/env zsh
# 批量无(必要)重编码/智能重编码手机视频脚本
# 支持输入:*.mp4 *.MP4 *.mov *.MOV *.3gp *.3GP
# 目标:在保持主观画质前提下,最大化减小体积。
# 平台:macOS (zsh)
# 依赖:ffmpeg ffprobe (建议 `brew install ffmpeg` 安装完整版)
# 可选:md5 (系统自带) 或 md5sum
# 日志:compressed/convert_log.csv
set -u
############################################
# 可调参数(也可通过命令行覆盖)
############################################
# 默认编码方式:hardware|software (hardware 使用 hevc_videotoolbox)
ENCODER_MODE="hardware"
# 软件 x265 CRF(数值小=质量高=文件大)
X265_CRF=26
X265_PRESET="medium" # medium / slow / slower
# 硬件 HEVC 质量参数 (0-100 越低越好),建议 45~55 之间
VT_QUALITY=50
# 仅当分辨率超过此高度才降到该高度 (0 表示不降)。例如 1080
MAX_HEIGHT=0
# 是否限制输出最大帧率 (0 表示保持原始)。例如 30
MAX_FPS=0
# 是否干跑(只打印计划,不真正执行) true|false
DRY_RUN=false
# 是否生成 MD5 校验(耗时) true|false
DO_MD5=false
# 已是 HEVC 时,按分辨率决定的码率阈值 (bps) 低于则直接 copy
THRESH_720=3000000 # 3 Mbps
THRESH_1080=8000000 # 8 Mbps
THRESH_2160=40000000 # 40 Mbps
# 目标目录
OUT_DIR="compressed"
# 是否覆盖已存在输出文件 true|false
OVERWRITE=false
# 保留原文件修改时间戳(touch -r) true|false
KEEP_MTIME=true
############################################
usage() {
cat <<EOF
用法: $0 [选项]
选项:
-m hardware|software 选择编码模式 (默认: hardware)
-c <crf> x265 CRF (软件模式时有效, 默认: ${X265_CRF})
-p <preset> x265 预设 (默认: ${X265_PRESET})
-q <qval> 硬件 hevc_videotoolbox 质量值 (默认: ${VT_QUALITY})
-H <height> 限制最大高度 (0=不变, 默认: ${MAX_HEIGHT})
-F <fps> 限制最大帧率 (0=不变, 默认: ${MAX_FPS})
-o <dir> 输出目录 (默认: ${OUT_DIR})
-n Dry-run 仅显示计划
-O 允许覆盖已存在输出文件
-M 生成 MD5 校验
-h 显示本帮助
示例:
# 快速批量(硬件 HEVC)
$0 -m hardware -q 48
# 精细压缩(软件 x265)
$0 -m software -c 25 -p slow
# 限制最高 1080p 和 30fps
$0 -H 1080 -F 30
EOF
}
while getopts ":m:c:p:q:H:F:o:nOMh" opt; do
case $opt in
m) ENCODER_MODE=$OPTARG ;;
c) X265_CRF=$OPTARG ;;
p) X265_PRESET=$OPTARG ;;
q) VT_QUALITY=$OPTARG ;;
H) MAX_HEIGHT=$OPTARG ;;
F) MAX_FPS=$OPTARG ;;
o) OUT_DIR=$OPTARG ;;
n) DRY_RUN=true ;;
O) OVERWRITE=true ;;
M) DO_MD5=true ;;
h) usage; exit 0 ;;
*) echo "未知参数: -$OPTARG" >&2; usage; exit 1 ;;
esac
done
command -v ffmpeg >/dev/null 2>&1 || { echo "需要 ffmpeg" >&2; exit 2; }
command -v ffprobe >/dev/null 2>&1 || { echo "需要 ffprobe" >&2; exit 2; }
mkdir -p "$OUT_DIR"
LOG="$OUT_DIR/convert_log.csv"
if [ ! -f "$LOG" ]; then
echo "source,action,orig_MB,new_MB,save_pct,codec_in,width,height,bitrate_in,encoder_used,params,elapsed_s" > "$LOG"
fi
ts() { date +%s; }
human_size_MB() { # bytes -> MB 整数
echo $(( $1 / 1024 / 1024 ))
}
pick_threshold() {
local h=$1
if (( h <= 800 )); then echo $THRESH_720; return; fi
if (( h <= 1300 )); then echo $THRESH_1080; return; fi
echo $THRESH_2160
}
calc_scale_filter() {
local h=$1
local vf=""
if (( MAX_HEIGHT > 0 && h > MAX_HEIGHT )); then
vf="scale=-2:${MAX_HEIGHT}"
fi
if (( MAX_FPS > 0 )); then
if [ -n "$vf" ]; then
vf="${vf},fps=${MAX_FPS}"
else
vf="fps=${MAX_FPS}"
fi
fi
echo $vf
}
compute_md5() {
local f=$1
if command -v md5 >/dev/null 2>&1; then md5 -r "$f" | awk '{print $1}';
elif command -v md5sum >/dev/null 2>&1; then md5sum "$f" | awk '{print $1}';
else echo "NO_MD5_TOOL"; fi
}
process_file() {
local f="$1"
local base="${f##*/}"
local name="${base%.*}"
local extlower="${f##*.}"; extlower="${extlower:l}" # zsh: to lower
case $extlower in
mp4|mov|3gp) : ;;
*) return ;;
esac
local out="$OUT_DIR/${name}.mp4"
if [ -f "$out" ] && ! $OVERWRITE; then
echo "跳过(已存在): $out"
return
fi
# ffprobe 取信息
local info
info=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,bit_rate -of default=nk=1:nw=1 "$f") || { echo "获取信息失败: $f"; return; }
local codec width height bitrate
codec=$(echo "$info" | sed -n '1p')
width=$(echo "$info" | sed -n '2p')
height=$(echo "$info" | sed -n '3p')
bitrate=$(echo "$info" | sed -n '4p')
[ -z "$bitrate" ] && bitrate=0
local thresh=$(pick_threshold "$height")
local action="reencode"
local encoder_used params cmd_v cmd_a
# 判定是否可直接封装复制
if [ "$codec" = "hevc" ] && [ "$extlower" != "3gp" ] && [ "$bitrate" -gt 0 ] && [ "$bitrate" -lt "$thresh" ] && (( MAX_HEIGHT == 0 || height <= MAX_HEIGHT )) && (( MAX_FPS == 0 )); then
action="copy"
fi
local vf=""; vf=$(calc_scale_filter "$height")
if [ "$action" = "copy" ]; then
cmd_v="-c:v copy"; cmd_a="-c:a copy"; encoder_used="copy"; params="-"
else
if [ "$ENCODER_MODE" = "software" ]; then
encoder_used="libx265"
cmd_v=( -c:v libx265 -preset "$X265_PRESET" -crf "$X265_CRF" -x265-params high-tier=1 )
params="crf=$X265_CRF;preset=$X265_PRESET"
else
encoder_used="hevc_videotoolbox"
cmd_v=( -c:v hevc_videotoolbox -q:v "$VT_QUALITY" )
params="q=$VT_QUALITY"
fi
cmd_a=( -c:a aac -b:a 128k )
fi
local vf_part=()
if [ -n "$vf" ]; then vf_part=( -vf "$vf" ); fi
local orig_size=$(stat -f%z "$f")
local t0=$(ts)
if $DRY_RUN; then
echo "[计划] $f -> $out | $action | codec_in=$codec ${width}x${height} br=${bitrate} thresh=${thresh} encoder=${encoder_used} ${params} vf='${vf}'"
return
fi
if [ "$action" = "copy" ]; then
# 复制模式仍显式复制全局元数据;use_metadata_tags 尝试保留更多标签名称
ffmpeg -y -i "$f" -map 0 -map_metadata 0 -c copy -movflags +faststart+use_metadata_tags "$out" >/dev/null 2>&1 || { echo "复制失败: $f"; return; }
else
# 重编码:-map 0 保留所有可用流(视频/音频/字幕/附加元数据流),如不需要可去掉
ffmpeg -y -i "$f" -map 0 ${vf_part[@]} ${cmd_v[@]} ${cmd_a[@]} -map_metadata 0 -movflags +faststart+use_metadata_tags "$out" >/dev/null 2>&1 || { echo "编码失败: $f"; return; }
fi
# 保留原文件修改时间(类似照片的时间线),方便按时间排序
if $KEEP_MTIME && [ -f "$out" ]; then
touch -r "$f" "$out" 2>/dev/null || true
fi
local t1=$(ts)
local new_size=$(stat -f%z "$out" 2>/dev/null || echo 0)
local orig_MB=$(human_size_MB $orig_size)
local new_MB=$(human_size_MB $new_size)
local save_pct=0
if [ $orig_size -gt 0 ]; then
save_pct=$(( (orig_size - new_size) * 100 / orig_size ))
fi
local elapsed=$(( t1 - t0 ))
echo "$f,$action,$orig_MB,$new_MB,$save_pct,$codec,$width,$height,$bitrate,$encoder_used,$params,$elapsed" >> "$LOG"
if $DO_MD5; then
local md5o=$(compute_md5 "$f")
local md5n=$(compute_md5 "$out")
echo "MD5 原: $md5o 新: $md5n ($out)"
fi
echo "完成: $f -> $out (节省 ${save_pct}%)"
}
shopt_nullglob() { :; } # 占位(zsh 不需要)
main() {
local count=0
for f in *(.N); do
case "${f##*.}" in
mp4|MP4|mov|MOV|3gp|3GP) process_file "$f"; count=$((count+1));;
esac
done
echo "处理完成: ${count} 个文件。日志: $LOG"
if $DRY_RUN; then echo "(Dry-run 模式未实际生成文件)"; fi
}
main "$@"
给微信视频设置近似时间的脚本
#!/usr/bin/env python3
"""
Set WeChat video file dates based on their source YYYY-MM folders.
- Scans the source root for subfolders named YYYY-MM (e.g. 2024-01).
- Builds a mapping: filename -> YYYY-MM (earliest month wins if duplicates exist).
- For each matching file in the target directory, sets the file's mtime (content modification time)
to "YYYY-MM-01 00:00:00 +0000" (UTC). On macOS, mdls shows this as kMDItemFSContentChangeDate.
Notes:
- This updates the POSIX mtime via os.utime; creation date is not modified.
- Run with --dry-run first to preview changes.
Example:
python3 wechat_video_date_fix.py \
--source-root /Users/onlyice/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{your-wxid}/msg/video \
--target-dir . \
--dry-run
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from datetime import datetime, timezone
from typing import Dict, Tuple
# Video extensions to consider (lowercase)
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".3gp", ".avi", ".mkv"}
YM_RE = re.compile(r"^(?P<y>\d{4})-(?P<m>0[1-9]|1[0-2])$")
def is_video_file(name: str) -> bool:
_, ext = os.path.splitext(name)
return ext.lower() in VIDEO_EXTS
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Set file mtime based on source YYYY-MM folders")
parser.add_argument(
"--source-root",
default=(
"/Users/onlyice/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
"xwechat_files/{your-wxid}/msg/video"
),
help="Path to the root folder containing YYYY-MM subfolders",
)
parser.add_argument(
"--target-dir",
default=".",
help="Directory where files without YYYY-MM structure live (default: current dir)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Only print what would change without modifying files",
)
parser.add_argument(
"--prefer-latest",
action="store_true",
help="If a filename exists in multiple months, prefer the latest month (default: earliest)",
)
return parser.parse_args()
def ym_key(ym: str) -> Tuple[int, int]:
m = YM_RE.match(ym)
if not m:
raise ValueError(f"Invalid YYYY-MM folder name: {ym}")
return int(m.group("y")), int(m.group("m"))
def build_mapping(source_root: str, prefer_latest: bool) -> Dict[str, str]:
mapping: Dict[str, str] = {}
try:
entries = sorted(os.listdir(source_root))
except FileNotFoundError:
print(f"[ERROR] Source root not found: {source_root}", file=sys.stderr)
sys.exit(1)
ym_dirs = [d for d in entries if YM_RE.match(d) and os.path.isdir(os.path.join(source_root, d))]
if not ym_dirs:
print(f"[WARN] No YYYY-MM folders found under: {source_root}")
for ym in sorted(ym_dirs):
ym_path = os.path.join(source_root, ym)
try:
for name in os.listdir(ym_path):
src = os.path.join(ym_path, name)
if not os.path.isfile(src):
continue
if not is_video_file(name):
continue
if name not in mapping:
mapping[name] = ym
else:
# Resolve duplicates per flag
y1, m1 = ym_key(mapping[name])
y2, m2 = ym_key(ym)
if prefer_latest:
if (y2, m2) > (y1, m1):
mapping[name] = ym
else:
if (y2, m2) < (y1, m1):
mapping[name] = ym
except PermissionError:
print(f"[WARN] Skipping (permission denied): {ym_path}")
print(f"[INFO] Built mapping for {len(mapping)} filenames from {len(ym_dirs)} month folders.")
return mapping
def month_start_utc_ts(ym: str) -> float:
y, m = ym_key(ym)
dt = datetime(y, m, 1, 0, 0, 0, tzinfo=timezone.utc)
return dt.timestamp()
def apply_dates(target_dir: str, mapping: Dict[str, str], dry_run: bool):
changed = 0
skipped_missing = 0
examined = 0
try:
for name in os.listdir(target_dir):
path = os.path.join(target_dir, name)
if not os.path.isfile(path):
continue
if not is_video_file(name):
continue
examined += 1
ym = mapping.get(name)
if not ym:
skipped_missing += 1
print(f"[SKIP] No month found for: {name}")
continue
ts = month_start_utc_ts(ym)
if dry_run:
print(f"[DRY] Would set {name} -> {ym}-01 00:00:00 +0000")
else:
try:
os.utime(path, (ts, ts)) # atime, mtime
changed += 1
print(f"[OK] Set {name} -> {ym}-01 00:00:00 +0000")
except PermissionError:
print(f"[ERROR] Permission denied setting time for: {name}")
except FileNotFoundError:
print(f"[ERROR] File disappeared while processing: {name}")
except FileNotFoundError:
print(f"[ERROR] Target dir not found: {target_dir}", file=sys.stderr)
sys.exit(1)
return changed, skipped_missing, examined
def main() -> None:
args = parse_args()
source_root = os.path.abspath(os.path.expanduser(args.source_root))
target_dir = os.path.abspath(os.path.expanduser(args.target_dir))
print(f"[INFO] Source root: {source_root}")
print(f"[INFO] Target dir : {target_dir}")
print(f"[INFO] Mode : {'DRY-RUN' if args.dry_run else 'APPLY'}")
print(f"[INFO] Duplicate policy: {'latest' if args.prefer_latest else 'earliest'}")
mapping = build_mapping(source_root, args.prefer_latest)
changed, skipped_missing, examined = apply_dates(target_dir, mapping, args.dry_run)
print("\n[SUMMARY]")
print(f" Examined target files: {examined}")
print(f" Updated timestamps : {changed}")
print(f" Missing in mapping : {skipped_missing}")
if args.dry_run:
print("\nRun again without --dry-run to apply the changes.")
else:
print("\nTip: If mdls still shows stale metadata, force reindex the target folder:")
print(f" mdimport -f '{target_dir}'")
if __name__ == "__main__":
main()