用 Python + wxPython 造一个照片工具箱:PDF / 加密ZIP / MP4 / 归档,以及我在这过程中踩到的 4 个坑

环境: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.jsonphoto2pdf.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))

两个决策:

  1. secrets 不用 randomrandom 是可预测的伪随机(梅森旋转),任何涉及密码/令牌的场景都必须用 secrets。这是安全底线。
  2. 剔除 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/照片.jpgB/照片.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()

密码错误时 pyzipperRuntimeError,捕获后转成人话提示,而不是让异常冒到顶层:

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=PIPEstderr=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.pathUNIQUE 约束,如果两张照片移动后撞成同一路径,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 记录全部保留、自动标记为 pdftarget_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_pathtarget_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}')

九、可以继续改进的地方

坦白说这份代码还有明显短板:

  1. 耗时操作跑在主线程 ,靠 wx.YieldIfNeeded() 刷界面。照片多时窗口会迟滞,正确做法是丢到 threading 并用 wx.CallAfter 回传进度。
  2. MainFrame 太胖(约 1500 行)。UI 构建、业务分派、数据库调用混在一起,该拆成 Controller。
  3. 没有单元测试文件 。验证是靠临时脚本做的,跑完就删了,应该固化成 pytest 用例。
  4. AES ZIP 的兼容性只能靠提示缓解,想让 Windows 原生解压支持,得改用传统 ZipCrypto(但那个加密强度很弱,不划算)。

十、结语

这个项目真正的技术含量不在"调用 reportlab 生成 PDF"或"调用 ffmpeg 生成视频"------那些都是几行 API。含量在于:

  • 知道 zipfile 不能写加密包,所以必须引入 pyzipper 并做优雅降级;
  • 知道 concat demuxer 会静默吞帧,所以改成先归一化再编码;
  • 知道双管道会死锁,所以用线程 drain stderr;
  • 知道 EXIF 方向和 Windows 路径大小写 这些"脏现实",所以代码里到处是 exif_transpose()norm()

写工具类程序,60% 的代码是在处理这些不优雅的现实。而其中最该警惕的,是那些不会抛异常的错误。

如果这篇文章帮你避开了其中任何一个坑,欢迎点赞收藏。有问题欢迎评论区交流。


本文所有代码片段来自可运行的完整程序,文中的错误信息、时长数据、版本号均为实测结果。

相关推荐
卷无止境1 小时前
FastAPI 后台任务的边界,以及 Celery、Redis 与自建调度系统的选择
后端·python
卷无止境1 小时前
Linux + Docker + FastAPI 工程化实践指南
后端·python·fastapi
聪明蛋子哟10 小时前
Stagehand v3多语言SDK:Python/Go/Rust/Java下的浏览器自动化统一方案
python·golang·rust
今天AI了吗11 小时前
Python 基础语法(一):常量、变量、输入输出与运算符
开发语言·数据库·人工智能·python·sql·深度学习·机器学习
卷无止境11 小时前
Windows 上丝滑开发 Python,并稳定构建 Docker 镜像
后端·python·docker
TELL52111 小时前
selenium webdriver 第二次初始化的异常
开发语言·python
2501_9307077812 小时前
使用C#代码在 PDF 文档中创建列表
pdf
Csvn12 小时前
🐍 Day 4: Python 控制流 — 条件、循环与推导式的艺术
后端·python
大鹏说大话14 小时前
从爬虫到决策引擎:大数据下自媒体如何用Python挖掘用户痛点
开发语言·爬虫·python