环境:Python 3.13.7 (64bit) · wxPython 4.2.4 (wxWidgets 3.2.8) · Pillow 11.3.0 · reportlab 4.4.9 · pyzipper 0.4.0 · FFmpeg N-111697 · Windows 11
一、整体架构
程序是单文件结构,分三层:
| 层 | 内容 | 特点 |
|---|---|---|
| 纯函数层 | create_pdf / create_zip / create_mp4 / move_images / find_* |
不碰 GUI,可单独测试 |
| 数据层 | Database 类,SQLite |
操作记录 + 照片清单 + 列表记忆 |
| 界面层 | MainFrame + 4 个 Dialog + PreviewPanel |
wxPython |
这个分层不是为了好看。纯函数层不导入 wx,意味着 ZIP 打包、视频合成、路径重写这些最容易出错的逻辑,都可以脱离界面用普通脚本压测------后文提到的 4 个坑,有 3 个是在这一层被测出来的。
配置与数据库放在程序同目录
python
def get_app_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent # PyInstaller 打包后
return Path(__file__).resolve().parent # 直接运行 .py
sys.frozen 是 PyInstaller 注入的标志。这样无论直接跑脚本还是打包成 exe,config.json 和 photo2pdf.db 都落在程序旁边,绿色免安装。
配置写入用了临时文件 + 原子替换,避免写一半断电导致配置损坏:
python
def save_config(cfg: dict) -> None:
tmp = CONFIG_PATH.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
tmp.replace(CONFIG_PATH) # 原子操作
"C:\Users\86182\Desktop\photoTOpdf\photoTOpdf.py"

二、照片 → PDF:为什么用"像素当磅"
2.1 页面尺寸的取舍
PDF 的单位是 point(磅,1/72 英寸),但这个程序的目标是电视/电脑全屏播放,不是打印。所以做了一个刻意的简化:
python
def page_size_from_mode(mode, custom_w, custom_h):
if mode == "16:9 1080p":
return 1920, 1080 # 直接把像素数字当 point 用
if mode == "16:9 4K":
return 3840, 2160
1920×1080 point 换算成物理尺寸是 26.7×15 英寸,作为"纸"很荒谬,但作为屏幕播放的画布,比例和版式完全正确,阅读器会自动缩放到屏幕。用途决定了单位可以"不讲究"------如果要送印刷厂就必须老老实实按毫米算。
2.2 完整显示(Contain)而不是裁切
核心是 prepare_image_for_pdf:把任意尺寸照片渲染成与页面等大的图。
python
im, _ = load_oriented_image(image_path)
inner_w = max(1, page_w - margin_px * 2)
inner_h = max(1, page_h - margin_px * 2)
src_w, src_h = im.size
scale = min(inner_w / src_w, inner_h / src_h) # 取小 = Contain
new_w = max(1, int(round(src_w * scale)))
new_h = max(1, int(round(src_h * scale)))
im = im.resize((new_w, new_h), Image.Resampling.LANCZOS)
page = Image.new("RGB", (page_w, page_h), bg)
x, y = (page_w - new_w) // 2, (page_h - new_h) // 2
if im.mode == "RGBA":
page.paste(im, (x, y), im) # 用自身 alpha 作蒙版
else:
page.paste(im, (x, y))
两个关键点:
min()而不是max():取小是 Contain(完整显示、留黑边),取大是 Cover(铺满、裁掉边缘)。相册场景绝不能裁人头,所以必须 Contain。- 透明 PNG 必须传第三个参数 。
page.paste(im, (x,y), im)里第三个im是蒙版,不传的话 RGBA 的透明区域会变成黑块。
2.3 EXIF 方向:一行代码避免"手机照片躺倒"
python
def load_oriented_image(image_path: Path, max_side=None):
with Image.open(image_path) as im:
im = ImageOps.exif_transpose(im) # 关键
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA" if "A" in im.getbands() else "RGB")
orig_size = im.size # 记录真实尺寸
if max_side and max(im.size) > max_side:
im.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
return im.copy(), orig_size
手机竖拍的照片,像素矩阵往往是横的,靠 EXIF Orientation 标记旋转。不处理就会整本相册躺倒。ImageOps.exif_transpose() 一行解决。
注意 return im.copy():with 块退出后原对象会被关闭,必须返回副本。
还有个细节------函数同时返回缩放后的图和原始尺寸 。这是我修 bug 修出来的设计:预览为省内存会把大图缩到 2200px,如果直接读缩放后的 .size 显示给用户,一张 4000×3000 的照片会被标成"2200×1650"。信息面板必须显示真实尺寸。
2.4 控制体积:先转 JPEG 再交给 reportlab
python
buf = io.BytesIO()
page_img.save(buf, format="JPEG", quality=..., optimize=True, progressive=True)
buf.seek(0)
c.drawImage(ImageReader(buf), 0, 0, width=page_w, height=page_h,
preserveAspectRatio=False, mask="auto")
如果把 PIL 图直接给 reportlab,它倾向于存成无损数据,几十张照片就能做出上百 MB 的 PDF。先在内存里压成 JPEG,体积可控。这里 preserveAspectRatio=False 是安全的------因为图已经和页面等大了。
2.5 播放偏好:写进去,但别指望
python
try:
c._doc.Catalog.setPageLayout("SinglePage")
if fullscreen:
c._doc.Catalog.showFullScreen()
c.setViewerPreference("FitWindow", True)
if auto_advance:
c.setPageDuration(max(1, int(advance_seconds)))
c.setPageTransition("Dissolve", duration=1)
except Exception:
pass
PDF 规范里有"全屏打开""自动翻页"这类阅读器偏好。Adobe Reader 一般遵守,浏览器内置阅读器基本无视 。这里用 try/except 包住并且失败就忽略------因为 c._doc 是 reportlab 私有属性,版本升级可能变。这是一个"锦上添花、不可依赖"的功能,代码结构上就要体现这一点。
三、加密 ZIP:标准库做不到的事
3.1 为什么必须引入 pyzipper
一个很多人不知道的事实:
python
>>> hasattr(zipfile.ZipFile, 'setencryption')
False
Python 标准库 zipfile 只能"读"带密码的 ZIP,不能"写"。 想生成加密 ZIP 就必须上第三方库。方案有 pyminizip(需要编译)和 pyzipper(纯 Python + AES-256)。选后者:
python
try:
import pyzipper
except ImportError:
pyzipper = None # 优雅降级
pyzipper 缺失时程序照常运行,只是加密选项自动禁用并给出安装提示,而不是启动崩溃。
python
if password:
if pyzipper is None:
raise RuntimeError("需要 pyzipper 才能生成加密 zip。\n请先安装:pip install pyzipper")
zf = pyzipper.AESZipFile(str(output_path), "w",
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES)
zf.setpassword(password.encode("utf-8"))
else:
zf = zipfile.ZipFile(str(output_path), "w", compression=zipfile.ZIP_DEFLATED)
⚠️ 必须告知用户的事 :AES-256 加密的 ZIP,Windows 资源管理器自带的解压打不开,需要 7-Zip / WinRAR。这不是 bug,是 WinZip AES 不属于 ZIP 原始规范。程序在选项对话框里明确写了这句话------否则用户会以为文件坏了。
3.2 密码生成:secrets 而非 random
python
PASSWORD_ALPHABET = (
"".join(c for c in string.ascii_uppercase if c not in "OI")
+ "".join(c for c in string.ascii_lowercase if c not in "l")
+ "".join(c for c in string.digits if c not in "01")
+ "#%+=?@-_"
)
def generate_password(length=16) -> str:
length = max(8, int(length))
return "".join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length))
两个决策:
- 用
secrets不用random。random是可预测的伪随机(梅森旋转),任何涉及密码/令牌的场景都必须用secrets。这是安全底线。 - 剔除
0O1lI。用户要抄密码,这几个字符在多数字体下难以区分。牺牲一点熵换可用性------字符表 65 个字符、16 位长度,熵约 96 bit,远超需求。
密码不写入配置文件也不写入数据库,只在生成后弹一次对话框(带复制按钮)。安全上这是对的,但代价是忘记就找不回,所以对话框里用了加粗提示。
3.3 ZIP 内改名:解决跨目录同名冲突
python
def unique_names(paths):
names, used = [], set()
for i, p in enumerate(paths, 1):
base = f"{i:03d}_{p.name}"
name, n = base, 2
while name.lower() in used:
name = f"{i:03d}_{p.stem}_{n}{p.suffix}"
n += 1
used.add(name.lower())
names.append(name)
return names
从 A/照片.jpg 和 B/照片.jpg 同时选照片是很常见的。直接打包会互相覆盖。加 001_ 前缀既避免冲突,又保留了播放顺序 (解压后按文件名排序即原顺序)。used 用小写比对,因为 Windows 不区分大小写。
3.4 加密 ZIP 内的照片预览:全程在内存里
这是我比较满意的一个功能:不解压就能看加密 ZIP 里的照片。
python
def load_image_from_zip(zf, name, max_side=None):
data = zf.read(name) # 解密 → bytes
with Image.open(io.BytesIO(data)) as im: # bytes → PIL,不落盘
im = ImageOps.exif_transpose(im)
...
return im.copy(), orig
io.BytesIO 把内存字节包装成"类文件对象",PIL 可以直接读。照片不会以明文形式落到磁盘------这对加密场景很重要,否则临时文件把加密的意义抵消了。
对话框把内存图直接塞进预览面板复用渲染逻辑:
python
im, orig = load_image_from_zip(self.zf, name, max_side=PREVIEW_MAX_SIDE)
self.preview._pil = im
self.preview._orig_size = orig
self.preview._bmp = None # 清掉缩放缓存
self.preview.Refresh()
密码错误时 pyzipper 抛 RuntimeError,捕获后转成人话提示,而不是让异常冒到顶层:
python
except Exception as e:
self.preview.clear()
msg = str(e)
if "password" in msg.lower() or isinstance(e, RuntimeError):
msg = "解密失败:密码错误或该 ZIP 使用了不支持的加密方式。"
self.info.SetLabel(msg)
四、照片 → MP4:本文最大的坑
这是全文最值得看的部分。我第一版实现是错的,而且错得很安静。
4.1 第一版:想当然地用 concat demuxer
网上搜"ffmpeg 图片合成视频",主流答案是 concat demuxer:写一个清单文件,每张图给一个 duration。
file 'C:/pics/a.jpg'
duration 3
file 'C:/pics/b.png'
duration 3
file 'C:/pics/b.png'
我照做了,还处理了中文路径、空格、单引号转义。测试跑通,没有任何报错,生成了 mp4。
然后我顺手 ffprobe 查了一下时长------3 张照片、每张 3 秒,本该 9 秒,实际 1.04 秒。
4.2 定位:concat demuxer 只认第一个文件的解码器
最小复现(本文写作时重新跑过一遍):
=== concat demuxer 混用 JPEG + PNG ===
stderr: [mjpeg @ ...] No JPEG data found in image
[vist#0:0/mjpeg @ ...] Error submitting packet to decoder: Invalid data found
期望 2 秒, 实际: 0.041667 ← 静默截断
原因清楚了:concat demuxer 会用第一个文件的解码器处理整个序列。第一张是 JPEG,它就挂上 mjpeg 解码器,后面每个 PNG 都报 "No JPEG data found" 然后被丢弃。
致命之处在于:ffmpeg 返回码是 0,文件也确实生成了。如果不主动去查时长,这个 bug 会一路带到用户手上------相册里混着 JPEG 和 PNG 太常见了。
4.3 第二版:先归一化,再用 image2 序列
放弃 concat,改成两步:先用 PIL 把每张照片渲染成尺寸、格式完全一致的临时 JPEG,再让 ffmpeg 读图片序列。
python
tmp_dir = Path(tempfile.mkdtemp(prefix="photo2mp4_"))
try:
# 1) 归一化:直接复用 PDF 的排版函数,视频和 PDF 版式完全一致
for idx, p in enumerate(image_paths):
frame = prepare_image_for_pdf(p, width, height, bg, 0, 92)
frame.save(str(tmp_dir / f"f{idx:06d}.jpg"), format="JPEG", quality=92)
# 2) 编码:image2 序列,1/per 的输入帧率 = 每张停留 per 秒
cmd = [
str(ffmpeg), "-y", "-hide_banner",
"-framerate", f"{1.0 / per:.6f}",
"-i", str(tmp_dir / "f%06d.jpg"),
"-r", str(int(fps)),
"-c:v", "libx264", "-preset", "medium", "-crf", str(int(crf)),
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-progress", "pipe:1", "-nostats",
str(output_path),
]
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
这个方案的收益超出预期:
- 任何格式/尺寸/EXIF 方向都能进,因为进 ffmpeg 之前已经统一了;
- 时长精确 :
-framerate 1/per决定每张停留多久,实测 4 张 × 2.5 秒 = 10.00 秒,不再需要 concat 那种"末帧重复 +-t裁剪"的补丁; - 版式和 PDF 完全一致 ,因为复用了同一个
prepare_image_for_pdf; - 顺带绕过了中文路径转义问题------临时文件名是纯 ASCII 的
f000001.jpg。
验证结果:
=== 归一化成同尺寸 JPEG 序列 ===
期望 2 秒, 实际: 2.000000 ← 精确
ffmpeg 报错: (无)
我还抽帧验证过内容正确性:在每段中点截图,比对中心像素颜色,三张纯色图 RGB 全部对上(误差 < 25),确认顺序和时间轴都没错。
4.4 两个必须注意的编码参数
python
width = max(2, int(width) // 2 * 2) # 强制偶数
height = max(2, int(height) // 2 * 2)
libx264 的 yuv420p 要求宽高能被 2 整除 ,否则直接报 width not divisible by 2 (801x601) 编码失败。我用 801×601 的照片测出过这个错。
-movflags +faststart 把索引(moov atom)挪到文件头,网页边下边播时能立刻开始播放。
4.5 第二个坑:读 stderr 的死锁
进度条要解析 -progress pipe:1 的输出。我的第一版:
python
# ❌ 会死锁
for line in proc.stdout: # 先把 stdout 读完
...
stderr = proc.stderr.read() # 再读 stderr
code = proc.wait()
测试直接卡死超过 120 秒。
原因是管道缓冲区容量有限(通常 64KB)。ffmpeg 往 stderr 写了大量编码日志,缓冲区一满,ffmpeg 就阻塞在写 stderr 上;而我的程序阻塞在读 stdout 上等它输出。互相等待,经典死锁。
正确做法是同时读两个管道:
python
stderr_chunks = []
def drain_stderr():
try:
for line in proc.stderr:
stderr_chunks.append(line)
except Exception:
pass
t = threading.Thread(target=drain_stderr, daemon=True)
t.start()
for line in proc.stdout: # 主线程读 stdout 解析进度
line = line.strip()
if line.startswith("out_time_ms=") and progress_cb:
ms = int(line.split("=", 1)[1])
pct = 40 + min(59, int(ms / 10000.0 / total_seconds * 0.6))
progress_cb(min(99, pct), total_seconds)
code = proc.wait()
t.join(timeout=5)
if code != 0:
tail = "\n".join("".join(stderr_chunks).strip().splitlines()[-8:])
raise RuntimeError(f"ffmpeg 返回错误码 {code}:\n{tail}")
💡 通用结论 :只要同时用
stdout=PIPE和stderr=PIPE,就必须并发读取,或者用communicate(),或者把 stderr 重定向到文件。顺序读两个管道 = 迟早死锁。这个坑和 ffmpeg 无关,任何输出量大的子进程都一样。
进度条做了分段映射:归一化占 0--40%,编码占 40--99%,完成才 100%。因为归一化(PIL 缩放)在照片多时并不快,如果这段进度条不动,用户会以为卡死了。
4.6 隐藏黑窗
python
creationflags = 0
if sys.platform.startswith("win"):
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
Windows 上不加这个标志,GUI 程序调用 ffmpeg 会闪出一个黑色控制台窗口。用 getattr 取常量是为了兼容老版本 Python。
五、移动照片:让数据库跟着走
这个功能表面简单(shutil.move),难点在移动之后,数据库里那些指向老路径的记录怎么办。
5.1 不覆盖任何文件
python
dst = target_dir / src.name
try:
if dst.exists() and dst.resolve() == src_res:
mapping[src] = dst # 已在目标目录,无需移动
continue
except Exception:
pass
n = 2
while dst.exists():
dst = target_dir / f"{src.stem}_{n}{src.suffix}"
n += 1
shutil.move(str(src), str(dst))
mapping[src] = dst
三个细节:
- 目标已有同名文件 → 自动加
_2、_3,绝不覆盖。用户的照片是不可再生数据,覆盖等于丢失。 - 用
resolve()比对,识别"照片已经在目标目录里"的情况,避免自己移动到自己。 - 返回
mapping = {原路径: 新路径},这是同步数据库的依据。 - 单张失败不中断整批,收集到
errors里最后统一报告。
5.2 路径重写:Windows 大小写不敏感
python
def update_image_paths(self, mapping):
def norm(s):
return str(s).replace("/", "\\").lower() if sys.platform.startswith("win") else str(s)
lookup = {norm(old): str(new) for old, new in mapping.items()}
changed = 0
for table in ("export_images", "recent_images"):
rows = self.conn.execute(f"SELECT id, path FROM {table}").fetchall()
updates = []
for row_id, old in rows:
new = lookup.get(norm(old))
if new and new != old:
updates.append((new, row_id))
if updates:
# recent_images.path 有 UNIQUE 约束,逐条更新并忽略冲突。
for new, row_id in updates:
try:
self.conn.execute(
f"UPDATE {table} SET path = ? WHERE id = ?", (new, row_id)
)
changed += 1
except sqlite3.IntegrityError:
# 两张照片移动后撞成同一路径,删掉重复行
self.conn.execute(f"DELETE FROM {table} WHERE id = ?", (row_id,))
self.conn.commit()
return changed
要点:
- 数据库里存的字符串可能是
C:/a/b.jpg,也可能是C:\a\b.jpg,大小写还可能不同 。Windows 视为同一文件,字符串比较却不等。所以统一norm()成小写反斜杠再比。 recent_images.path有UNIQUE约束,如果两张照片移动后撞成同一路径,UPDATE会抛IntegrityError。这里捕获并删除重复行,而不是让整批操作崩掉。
效果是:移动照片后,之前那条 PDF 记录依然能正确列出它用过的照片,点进去还能预览。
六、操作记录系统:从"PDF 历史"演进为通用日志
最初数据库里只有 PDF 记录。后来要把 ZIP / MP4 / 移动 也记进去,于是有了这次表结构演进。
6.1 用 ALTER TABLE 做原地迁移
已有用户的数据库里有真实记录,不能推倒重建。 做法是启动时检查列是否存在,缺了就补:
python
def _migrate_operations(self):
cols = {row[1] for row in self.conn.execute("PRAGMA table_info(export_history)")}
if "op_type" not in cols:
self.conn.execute(
"ALTER TABLE export_history ADD COLUMN op_type TEXT NOT NULL DEFAULT 'pdf'")
if "target_path" not in cols:
self.conn.execute("ALTER TABLE export_history ADD COLUMN target_path TEXT")
if "detail" not in cols:
self.conn.execute("ALTER TABLE export_history ADD COLUMN detail TEXT")
# 老记录都是 PDF:补 op_type,并让 target_path 指向自身
self.conn.execute("UPDATE export_history SET op_type = 'pdf' "
"WHERE op_type IS NULL OR op_type = ''")
self.conn.execute("UPDATE export_history SET target_path = output_path "
"WHERE target_path IS NULL OR target_path = ''")
PRAGMA table_info 是 SQLite 查表结构的标准手段。这段代码幂等------重复执行无副作用,所以每次启动都跑一遍最省心,不需要维护版本号。
我拿真实数据库的副本验证过:5 条历史 PDF 记录全部保留、自动标记为 pdf、target_path 正确回填,二次迁移结果不变。
6.2 表结构设计:两个路径字段
sql
CREATE TABLE export_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
output_path TEXT NOT NULL, -- 给用户看的结果
target_path TEXT, -- 双击时要打开的东西
op_type TEXT NOT NULL DEFAULT 'pdf',
detail TEXT,
image_count INTEGER NOT NULL,
created_at TEXT NOT NULL,
...
);
CREATE TABLE export_images ( -- 每次操作用到的照片清单
id INTEGER PRIMARY KEY AUTOINCREMENT,
export_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL,
FOREIGN KEY(export_id) REFERENCES export_history(id) ON DELETE CASCADE
);
为什么要 output_path 和 target_path 两个字段?因为**"结果"和"要打开的东西"不总是同一个**。对 PDF/ZIP/MP4 它们相同;对"移动"操作,结果是一批照片,而双击应该打开目标文件夹。分开存,双击逻辑就不用做特例判断。
sort_order 保证照片顺序可还原------顺序是相册的核心信息。
6.3 每次操作独立成条
这是个产品决策。之前的 PDF 历史用 MAX(id) GROUP BY output_path 做了去重,同一文件只留最新。但作为操作日志,用户想看到完整历史:同一个 PDF 生成过 3 次,就该有 3 条带各自时间戳的记录。
python
sql.append("ORDER BY created_at DESC, id DESC LIMIT ?") # 不再 GROUP BY
6.4 搜索:三字段联合匹配
python
if kw:
sql.append("AND (output_path LIKE ? OR target_path LIKE ? OR detail LIKE ?)")
like = f"%{kw}%"
params += [like, like, like]
if op_types:
marks = ",".join("?" for _ in op_types)
sql.append(f"AND op_type IN ({marks})")
params += list(op_types)
同时搜 3 个字段,于是:搜 相册 命中文件名,搜 归档 命中移动的目标目录,搜 已加密 命中备注。
注意占位符的用法 :op_types 的个数不固定,用 ",".join("?" for _ in op_types) 动态生成占位符,值仍然通过 params 传。绝不能把用户输入拼进 SQL 字符串 ------那是 SQL 注入。这里唯一拼进 SQL 的是 ? 号本身。
6.5 双击分派:按类型调不同程序
源码里是"命中即 return"的结构(下面为节选,省略了状态栏文本和回退对话框):
python
if op == "move":
if reveal_in_explorer(target): # 打开目标文件夹
self.status.SetLabel(f"已打开文件夹:{target}")
return
if op == "zip":
# 直接用记录里的路径打开预览,不需要再选文件
password = None
if self.last_zip_path and Path(self.last_zip_path) == target:
password = self.last_zip_password # 本次会话生成的,复用
if password is None and self._zip_needs_password(target):
pwd_dlg = wx.PasswordEntryDialog(
self, f"该 ZIP 已加密,请输入密码:\n{target.name}", "需要密码")
...
d = ZipPreviewDialog(self, target, password)
try:
d.ShowModal()
finally:
d.Destroy()
return
if op == "mp4":
if open_with_vlc(target):
self.status.SetLabel(f"已用 VLC 播放:{target.name}")
return
# 找不到 VLC → 询问是否用系统默认播放器
...
return
# 默认按 PDF 处理:Chrome 优先
if open_with_chrome(target):
self.status.SetLabel(f"已用 Chrome 打开:{target.name}")
return
# 找不到 Chrome → 询问是否用系统默认程序
配套的程序定位函数,以 VLC 为例,三级查找:
python
def find_vlc():
found = shutil.which("vlc") # 1. PATH
if found:
return Path(found)
if sys.platform.startswith("win"):
import winreg
for root, key_path, value_name in (
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\vlc.exe", ""),
(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\vlc.exe", ""),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VideoLAN\VLC", "InstallDir"),
):
try:
with winreg.OpenKey(root, key_path) as key:
value, _ = winreg.QueryValueEx(key, value_name)
p = Path(str(value).strip('"'))
if p.is_dir():
p = p / "vlc.exe" # InstallDir 给的是目录
if p.exists():
return p
except OSError:
continue
# 3. 常见安装目录 PROGRAMFILES / PROGRAMFILES(X86)
VLC 默认不进 PATH,但一定会写注册表 App Paths。这是 Windows 上定位已安装程序最可靠的办法。每一级都能失败并往下走,全失败就回退系统默认程序并告知用户------不静默失败。
Chrome 打开文件用 URI 而非裸路径:
python
subprocess.Popen([str(chrome), path.as_uri()])
Path.as_uri() 会把空格转成 %20、正确编码中文,得到 file:///C:/out/a%20b.pdf。直接传路径遇到空格会被 Chrome 拆成两个参数。
七、界面实现中的难点
7.1 自绘预览面板:双缓冲 + 双层缓存
python
class PreviewPanel(wx.Panel):
def __init__(self, parent):
super().__init__(parent, style=wx.BORDER_SIMPLE)
self.SetBackgroundStyle(wx.BG_STYLE_PAINT) # 关键:自己画背景
...
def on_paint(self, event):
dc = wx.AutoBufferedPaintDC(self) # 双缓冲,防闪烁
dc.SetBackground(wx.Brush(self.GetBackgroundColour()))
dc.Clear()
w, h = self.GetClientSize()
...
pad = 8
bmp = self._build_bitmap(max(1, w - pad * 2), max(1, h - pad * 2))
x = (w - bmp.GetWidth()) // 2
y = (h - bmp.GetHeight()) // 2
dc.DrawBitmap(bmp, x, y, useMask=False)
wx.BG_STYLE_PAINT+wx.AutoBufferedPaintDC:wxPython 防闪烁的标准组合。缺了会在缩放窗口时看到明显闪动。- 两层缓存 :
_pil缓存解码结果(避免重复读盘解码),_bmp+_bmp_key缓存缩放结果。_bmp_key = (宽, 高, 路径),尺寸没变就直接复用位图。EVT_SIZE里只清缓存不重新解码。
PIL 转 wx 位图:
python
wx_img = wx.Image(im.width, im.height)
wx_img.SetData(im.tobytes()) # 必须是 RGB(3 通道),不能带 alpha
self._bmp = wx_img.ConvertToBitmap()
SetData 只接受 24 位 RGB 裸数据,所以前面必须 convert("RGB")。
7.2 拖拽排序:两个 wx 陷阱
wx.ListBox 没有内置拖拽排序,需要自己处理 LEFT_DOWN / MOTION / LEFT_UP。这里踩了两个坑。
坑 3:ReleaseMouse() 在 capture-lost 里会崩溃
第一版程序在拖拽后抛出:
wx._core.wxAssertionError: C++ assertion ""!wxMouseCapture::stack.empty()"" failed
in wxWindowBase::ReleaseMouse(): Releasing mouse capture but capture stack empty?
原因:wx 在发出 EVT_MOUSE_CAPTURE_LOST 之前,已经把捕获栈弹空了 。我在该事件里调 ReleaseMouse(),等于释放一个不存在的捕获。而我用 HasCapture() 做的保护在 MSW 上此时并不可靠。
wx 文档明确写了:不要在 capture-lost 处理函数里调 ReleaseMouse()。修法是自己维护捕获状态:
python
def on_list_capture_lost(self, event):
"""wx 在发出该事件前已经释放了捕获,此处绝不能再调用 ReleaseMouse()。"""
self._has_capture = False # 只清状态
self._dragging = False
self.listbox.SetCursor(wx.Cursor(wx.CURSOR_DEFAULT))
self._reset_drag()
def _end_drag(self):
self._dragging = False
if self._has_capture: # 用自己的标志,不用 HasCapture()
self._has_capture = False
try:
self.listbox.ReleaseMouse()
except Exception:
pass
坑 4:LB_EXTENDED 的框选会污染选中集
这个更隐蔽------不报错,但数据错 。列表是 wx.LB_EXTENDED 多选样式,拖拽经过其它条目时,原生控件会把选中范围一路扩展。如果在放手时 才读 GetSelections(),把第 1 项往第 5 项拖,会变成"移动第 1--5 项",而不是"移动第 1 项"。
修法两条:
python
def on_list_left_down(self, event):
...
# 在原生控件改动选中项之前先记录一份快照
self._drag_selection = list(self.listbox.GetSelections())
def on_list_motion(self, event):
...
# 拖拽期间不要 Skip(),否则原生控件会同时做框选
target = self._insert_index_at(pos)
即:按下时快照选中集 ,且拖拽中不 event.Skip()(不把事件交还原生控件)。
插入位置的计算也有讲究:
python
before = sum(1 for i in selections if i < target)
if target <= selections[0]:
insert_at = target # 向上拖:插到目标之前
else:
insert_at = target - before + 1 # 向下拖:插到目标之后
insert_at = max(0, min(len(remaining), insert_at))
因为被拖的项会先从列表里摘掉,向下拖时目标索引要减去"前面被摘走的数量"。这段逻辑我用 11 组用例覆盖过(单项/多项、向上/向下、拖到空白区、拖回自身),全部通过。
一键倒排序则简单得多,但要注意别动数据库:
python
def on_reverse(self, event):
if len(self.image_paths) < 2:
return
self.image_paths.reverse()
self.refresh_list(keep_selection=[0])
self.update_preview(0)
7.3 浏览记录模式:避免"看历史"把当前列表搞坏
点下方记录,上方列表显示那次操作用过的照片。实现上有个真实风险:上方列表既是"用户正在编辑的列表",又是"导出/保存的数据源"。直接覆盖它,用户的工作成果就没了。
解法是进入浏览时把用户列表暂存:
python
if self.browsing_export_id is None:
self.working_paths = list(self.image_paths) # 暂存
self.browsing_export_id = item["id"]
self.image_paths = paths # 显示记录里的照片
并把"保存"和"操作"两个语义分开:
python
def _paths_for_save(self):
"""用于"记住当前列表"------始终是用户自己的列表"""
if self.browsing_export_id is not None and self.working_paths is not None:
return self.working_paths
return self.image_paths
于是:ZIP/MP4/移动/生成PDF 作用于"屏幕上显示的照片"(所以能直接对记录里的照片再操作),而**"记住列表"和退出时的持久化永远用用户自己的列表**。
还有一个连带问题:如果移动的照片同时存在于用户列表里,暂存的那份也必须一起改路径,否则"返回我的列表"会得到一堆失效路径:
python
self.image_paths = [mapping.get(p, p) for p in self.image_paths]
# 暂存的用户列表里可能也有这些照片,一并更新
if self.working_paths is not None:
self.working_paths = [mapping.get(p, p) for p in self.working_paths]
浏览模式下只锁"改动列表内容"的按钮(删除/上移/下移/清空),其余照常可用:
python
def _set_edit_enabled(self, enabled):
for btn in (self.btn_remove, self.btn_up, self.btn_down, self.btn_clear):
btn.Enable(enabled)
八、四个坑的总结
| # | 坑 | 症状 | 根因 | 教训 |
|---|---|---|---|---|
| 1 | ffmpeg concat demuxer | 不报错,视频被静默截断(9 秒→1 秒) | concat 用第一个文件的解码器处理整个序列,混用 JPEG/PNG 时后续帧被丢弃 | 生成类功能必须验证产物(时长/页数/尺寸),不能只看返回码 |
| 2 | 顺序读 stdout / stderr | 进程永久挂死 | 管道缓冲区满后子进程阻塞在写,父进程阻塞在读 | 双管道必须并发读,或用 communicate() |
| 3 | capture-lost 里 ReleaseMouse() |
C++ 断言崩溃 | wx 发事件前已弹空捕获栈,HasCapture() 不可靠 |
自己维护捕获状态;读框架文档的"不要做什么" |
| 4 | LB_EXTENDED 拖拽 |
不报错,误移动一整片条目 | 原生控件在拖拽中扩展了选中范围 | 交互开始时快照状态,别在结束时才读 |
其中 1 和 4 是"静默错误"------程序不崩、不报错,只是结果不对。这类 bug 最危险,因为常规测试(跑通了就算过)根本发现不了。
我的应对是验证产物而非验证流程:生成 MP4 后用 ffprobe 查时长、抽帧比对像素颜色;生成 PDF 后用 pypdf 查页数和页面尺寸;打包加密 ZIP 后真的用密码解回来、再用错误密码确认会失败;移动照片前先在目标目录放一个同名文件,确认原文件没被覆盖。
另外把 wx 断言变成异常,能让 GUI 层的隐性错误浮出来:
python
class App(wx.App):
def OnAssertFailure(self, file, line, func, cond, msg):
errors.append(f'{cond} {msg}')
九、可以继续改进的地方
坦白说这份代码还有明显短板:
- 耗时操作跑在主线程 ,靠
wx.YieldIfNeeded()刷界面。照片多时窗口会迟滞,正确做法是丢到threading并用wx.CallAfter回传进度。 MainFrame太胖(约 1500 行)。UI 构建、业务分派、数据库调用混在一起,该拆成 Controller。- 没有单元测试文件 。验证是靠临时脚本做的,跑完就删了,应该固化成
pytest用例。 - AES ZIP 的兼容性只能靠提示缓解,想让 Windows 原生解压支持,得改用传统 ZipCrypto(但那个加密强度很弱,不划算)。
十、结语
这个项目真正的技术含量不在"调用 reportlab 生成 PDF"或"调用 ffmpeg 生成视频"------那些都是几行 API。含量在于:
- 知道
zipfile不能写加密包,所以必须引入 pyzipper 并做优雅降级; - 知道 concat demuxer 会静默吞帧,所以改成先归一化再编码;
- 知道双管道会死锁,所以用线程 drain stderr;
- 知道 EXIF 方向和 Windows 路径大小写 这些"脏现实",所以代码里到处是
exif_transpose()和norm()。
写工具类程序,60% 的代码是在处理这些不优雅的现实。而其中最该警惕的,是那些不会抛异常的错误。
如果这篇文章帮你避开了其中任何一个坑,欢迎点赞收藏。有问题欢迎评论区交流。
本文所有代码片段来自可运行的完整程序,文中的错误信息、时长数据、版本号均为实测结果。