
做视频压缩这件事我最早是直接用命令行敲 FFmpeg 的。直到有一次要处理一批几百个视频文件每个还要按不同目录、不同分辨率规则生成压缩版本我才意识到纯手工敲命令、一条条转效率低到让人崩溃。于是我把整套流程从命令行搬进了 Python用脚本统一调度一条命令跑完整个压缩任务顺手还能记录日志、统计压缩前后的大小变化。今天这篇就把我自己落地这套方案的完整过程拆开聊清楚 Python 做视频压缩能做什么、怎么设计、有哪些坑。1. 压缩原理与 Python 的定位动刀之前先看清三个旋钮1.1 视频为什么能压小空间冗余和时间冗余很多人以为视频压缩就是把画面调模糊这是个误区。视频本质上是一连串按时间排列的图像帧如果不做任何压缩体积会大到不可思议。以常见的 1080p 30fps 画面为例一帧 1920×1080 的像素按 8bit 位深、YUV 4:2:0 采样来算每帧大约 3MB 左右每秒 30 帧就是 90 多 MB10 分钟的视频裸数据能到 50 多 GB。而我们平时看到的 1080p 视频10 分钟往往只有几百 MB 到 1GB 出头这中间的差距全靠压缩算法填平。压缩能成立靠的是两类冗余。一类是空间冗余同一帧画面上大面积天空、纯色墙面、平滑渐变区域相邻像素高度相似编码器可以用离散余弦变换之类的数学工具把这些信息变换到频域再把高频细节量化掉。另一类是时间冗余视频前后帧之间通常变化很小人物动一动、镜头摇一摇大部分背景像素其实没变所以编码器会存一个完整的参考帧I 帧后面很多帧只记录“跟上一帧相比哪里变了”P 帧和 B 帧。简单说压缩就是拿轻微的、人眼不敏感的画质损失换数量级的体积下降而编码器的工作就是把“哪些细节可以丢”这件事判断得足够准。1.2 Python 在这件事里的真实定位必须先把话说清楚Python 不是用来重新实现编解码器的。你不可能用纯 Python 去写一个 H.264 编码器出来那不现实性能也完全撑不住。Python 在这类任务里的真实角色是“调度中枢”读视频元数据、判断当前文件该用哪套参数、调用底层工具执行转码、检查输出结果、记录日志、处理批量异常。打个比方用 Python 做视频压缩你不是厨师而是餐厅的排菜经理。你不需要自己种菜、磨盐、掌勺你要做的是把菜谱参数模板定好把单子文件清单派下去盯着出品转码结果有没有问题。以我自己处理个人视频存档的经历来说三百多个文件如果纯靠命令行手动操作每个文件至少要先 ffprobe 看参数再敲一转码命令完了还要确认输出正常三五个命令下来人不累死也烦死了。把这些流程脚本化之后同样的量一条命令跑完人只需要最后看一眼报告。2. 方案选型为什么用“FFmpeg subprocess”而不是现成库2.1 四条主流的 Python 路线横向对比在动手写代码之前我先梳理过 Python 生态里做视频压缩的几条常见路线各有各的适用场景选错路线的代价往往在批量任务里才会显现。路线优点缺点适合场景直接 subprocess 调用 FFmpeg参数完整直通、性能等同原生、批量友好、跨平台需要本机装 FFmpeg对调用者有一点命令行基础要求几乎一切严肃的批量压缩任务MoviePy 这类封装库接口简洁、新手容易上手、写起来短内部多包一层封装转码效率和临时文件开销不占优复杂参数反而不容易透传单文件快速处理、原型验证OpenCV 的 VideoWriter可以直接操作帧、处理速度快编码器支持有限、无法保留音轨、压缩质量和码率控制弱实时视频处理、帧级算法场景imageio-ffmpeg 这类自带二进制方案纯 pip 安装即可用、离线环境友好自带的 FFmpeg 功能裁剪较多参数暴露不全快速实验、受限环境PyAV 这类 FFmpeg 绑定库可以精细控制解码编码的每一帧学习成本高普通压缩场景属于杀鸡用牛刀需要逐帧处理或二次开发的场景2.2 为什么 subprocess 反而是最可靠的“懒人”方案不少人一听 subprocess 就觉得“太底层、容易翻车”我自己最开始也用过封装库。但踩过一轮坑之后我的结论是对于视频压缩这种“外部工具已经非常成熟”的任务直接调 FFmpeg 子进程恰恰是最稳、最不折腾的方案。原因有三点。第一你不需要在 Python 里再包装一层可能失真的 API。FFmpeg 的命令行参数就是它最完整、最权威的接口直接传参数列表等于把 FFmpeg 的全部能力都握在手里任何新出的编码器、滤镜、封装选项都能第一时间用上不会被封装库的版本滞后卡住。第二subprocess 传参数用列表而不是拼接字符串天然避免了很多引号、转义、注入类问题。比如文件名里有空格、中文、括号用列表传递时它们就是普通参数完全不用手动转义。第三性能和资源消耗最透明。FFmpeg 自己在文件层面干活Python 进程只是“下发任务、等待结果”没有多余的内存拷贝也没有封装库常见的临时中间文件堆积问题。我自己最初用某个封装库跑批量转码跑到第 80 个文件时进程直接卡死连是哪个文件出了问题都定位不到。换成直接 subprocess 调度之后控制权回到自己手里每个文件单独一个子进程异常、日志、重试都清清楚楚问题一下就解决了。3. 核心参数拆解CRF、preset、缩放和音频到底怎么设3.1 编码器与 CRF质量优先还是体积优先参数选型是整个压缩方案里最值得花心思的部分因为它直接决定了“压完之后还能不能看”。编码器方面兼容性首选是 H.264在 FFmpeg 里对应 libx264。它的兼容性覆盖了几乎所有播放器、手机、剪辑软件是默认不会出错的选项。如果对体积有更极端的追求可以用 H.265对应 libx265同画质下体积大约能再省 30% 到 50%但编码速度慢不少而且老设备、老播放器经常不支持。我的建议是除非你明确知道自己文件的播放环境都是新设备新软件否则老老实实用 H.264。码率控制是另一个关键维度。日常场景最推荐 CRF恒定质量因子它不是一个具体的码率值而是一个“画质档位”。CRF 的取值范围是 0 到 51数字越小画质越好、文件越大反过来数字越大画质越差、文件越小。实践中最常用的是 18 到 28 这个区间18 基本视觉无损适合精修存档23 是平衡默认值日常通用26 到 28 能明显压小体积适合上传网络平台超过 30 之后画面细节很容易出现涂抹感除非你的视频本身是低清素材或者动画否则不建议碰。CRF 有个特点要注意它只承诺画质不承诺体积。同样 CRF 23一段静态访谈视频可能压得很小一段画面快速变化的体育视频可能压完还是很大。如果你需要严格控制输出体积比如上传平台限制单个文件 300MB那就该用“目标码率 两遍编码”方案。目标码率的计算方式很简单目标体积字节× 8 ÷ 时长秒 码率bps。比如想把 10 分钟的视频压到 300MB 左右就是 300 × 1024 × 1024 × 8 ÷ 600 ≈ 4.2Mbps可以两遍编码锁这个码率体积就八九不离十了。3.2 preset 的取舍省时间还是省体积preset 参数是很多人容易忽略的旋钮。它控制编码器的“努力程度”从 ultrafast、veryfast、faster、fast、medium、slow、slower 到 veryslow越往后编码越慢但同画质下压出来的体积越小。这里面的权衡非常实际。我实测过一个 10 分钟 1080p 视频用 medium 比 veryfast 大约慢两到三倍但输出体积能再省 10% 到 20%。对于大批量任务来说这个差距很值得算一笔账三百个文件如果每个 medium 压 8 分钟总共要四十多小时全部换 veryfast可能十个小时就跑完了。我的习惯是第一次跑批量用 veryfast 快速出一版确认参数没问题、播放正常之后再挑里面的重点文件用 medium 重新精压一遍。时间成本也是成本参数没有绝对最优只有适合当前场景的平衡点。3.3 音频与封装最容易做错的两个小细节视频压缩里最容易翻车的其实不是视频流而是音频流和封装层面的小细节。音频方面有两种处理思路。一种是直接-c:a copy把原始音轨原样复制进新文件速度快、完全无损但如果源文件的音频编码格式和输出容器不匹配就可能在播放器里出问题。另一种是重新编码成 AAC比如-c:a aac -b:a 128k这是最稳的通用方案兼容性好体积也不大。我默认推荐后者只有当你特别在意音频品质或者源文件已经是高质量 AAC 时才用 copy。封装层面有两个参数强烈建议加上。一个是-movflags faststart它会把 MP4 的索引信息moov挪到文件头部。没有这个参数时在线播放器必须先把整个文件下载完才能开始播放有了它视频就能边下边播体验差距非常明显。另一个是-pix_fmt yuv420p它能强制输出格式为最通用的 YUV 4:2:0 8bit 像素格式避免某些编码器输出奇怪的像素格式导致部分播放器黑屏。这两个参数不加很多场景也能出片但加上之后兼容性会稳定得多。4. 落地实现脚本结构、批量流程与进度显示4.1 环境准备与二进制检查动手写代码之前先确认环境。Python 版本 3.8 以上就好不需要装什么重型依赖核心只用标准库。真正关键的是本机要有完整的 FFmpeg 和 ffprobe 可执行文件并且能被命令行直接找到。我的检查习惯是三步先跑ffmpeg -version确认能正常调用再看版本说明里的编译选项确认包含 libx264 等常用编码器。特别提醒一句网上有很多精简版 FFmpeg去掉了一堆编码器体积小但功能残疾遇到Unknown encoder类报错往往就是栽在这上面。装一个完整构建版后面能省很多事。4.2 核心压缩函数下面是整个方案的核心。这个函数接收输入文件、输出文件、CRF、preset、目标宽度等参数拼好 FFmpeg 命令后用 subprocess 执行并返回压缩前后的文件大小。import subprocess from pathlib import Path def compress_video( input_path: Path, output_path: Path, crf: int 23, preset: str medium, target_width: int | None None, keep_audio: bool True, audio_bitrate: str 128k, ) - dict: 把单个视频压缩成 H.264/AAC 的 MP4 文件返回压缩前后大小。 cmd [ ffmpeg, -y, # 输出文件已存在时直接覆盖 -i, str(input_path), # 输入文件 -c:v, libx264, # 视频编码器 -crf, str(crf), # 质量档位 -preset, preset, # 编码速度与压缩率权衡 -pix_fmt, yuv420p, # 通用像素格式 ] if target_width: # -2 表示高度按原始宽高比自动计算同时强制为偶数规避编码报错 cmd [-vf, fscale{target_width}:-2] if keep_audio: cmd [-c:a, aac, -b:a, audio_bitrate] else: cmd [-an] cmd [-movflags, faststart, str(output_path)] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(fffmpeg 执行失败最后 500 字错误信息\n{result.stderr[-500:]}) return { input_size: input_path.stat().st_size, output_size: output_path.stat().st_size, }这里每个参数都和前面讲的原理对应上了libx264 保证兼容性CRF 控制画质preset 控制速度和体积的平衡yuv420p 保证播放兼容faststart 保证在线播放体验。scale1280:-2里的 -2 是个很实用的技巧它让 FFmpeg 自己根据宽高比算高度并且自动把结果取成偶数。这样做的好处是规避了“高度不是偶数导致编码失败”的经典报错。如果你只想缩小大视频、不想把小视频放大可以把缩放条件写成动态判断当源文件宽度大于 1920 时才转成 1920否则保留原始分辨率。-vf scalemin(1280,iw):-2这种表达式可以做到这一点用 Python 传参时注意引号别写乱就行。4.3 用 ffprobe 读元数据让脚本自动选参数批量处理时每个文件的原始分辨率和码率都不一样靠人工一个个看参数再决定怎么压又回到老路上了。所以我加了一步自动探测用 ffprobe 把视频的分辨率、比特率、时长读出来脚本再基于这些信息自动决定参数。import json import subprocess from pathlib import Path def probe_video(path: Path) - dict: 读取视频第一路视频流的宽高、帧率、码率和封装格式信息。 cmd [ ffprobe, -v, error, -select_streams, v:0, -show_entries, streamwidth,height,r_frame_rate,bit_rate, -show_format, -of, json, str(path), ] raw subprocess.check_output(cmd, textTrue) return json.loads(raw)拿到这些信息后参数决策逻辑就可以很简单比如宽度大于 1920 的目标宽度设为 1920压缩后保留 1080p 清晰度源文件大于 500MB 的CRF 提到 26优先保证体积下降源文件本身就小于 200MB 的直接跳过没必要画蛇添足。这套规则不一定适合所有人但思路是通用的把“你平时人工判断的规则”翻译成简单的判断语句脚本就有了基本的智能。4.4 批量压缩与报告落地单文件函数写好之后批量处理就只是遍历文件列表的问题了。我一般会写一个 batch_compress 函数遍历指定目录下的所有视频文件逐个调用 compress_video把成功和失败的结果都记下来最后导出一份 CSV 报告。import csv import sys from pathlib import Path def batch_compress(folder: Path, extensions(.mp4, .mov, .avi, .mkv)) - list: files [p for p in folder.rglob(*) if p.suffix.lower() in extensions] report [] for i, video in enumerate(files, 1): out video.with_name(video.stem _compressed.mp4) try: info compress_video(video, out) ratio info[output_size] / info[input_size] * 100 report.append([video.name, info[input_size], info[output_size], round(ratio, 1), ok, ]) print(f[{i}/{len(files)}] {video.name}: 压缩后为原体积的 {ratio:.1f}%) except Exception as exc: report.append([video.name, 0, 0, 0, failed, str(exc)]) print(f[{i}/{len(files)}] {video.name}: 失败 - {exc}, filesys.stderr) return report def save_report(report: list, report_path: Path) - None: with open(report_path, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([文件名, 压缩前字节, 压缩后字节, 体积比例%, 状态, 错误信息]) writer.writerows(report)输出文件名统一加_compressed后缀而不是直接覆盖源文件这是一个安全上的底线。转码过程中如果程序崩溃、磁盘满了、参数写错了至少原始文件还在不会越压越损失。4.5 进度显示的两种做法批量任务一跑就是几十分钟没有进度反馈会让人很焦躁。进度显示可以分成两个层级。第一种是文件级别的进度用简单的 print 输出当前处理到第几个文件、上一个文件压完的百分比。实现最简单也够用。第二种是单文件内部的实时进度FFmpeg 提供-progress pipe:1参数会把编码进度输出到 stdout我们可以在 Python 里逐行解析算出当前文件已经处理到百分之几。import re import subprocess progress_re re.compile(rout_time(\d):(\d):(\d)\.(\d)) def compress_with_progress(src, dst, duration_sec): cmd [ffmpeg, -i, str(src), -progress, pipe:1, -nostats, -c:v, libx264, -crf, 23, -preset, medium, -c:a, aac, -b:a, 128k, -y, str(dst)] proc subprocess.Popen( cmd, stdoutsubprocess.PIPE, stderrsubprocess.DEVNULL, textTrue ) for line in proc.stdout: m progress_re.search(line) if m: current int(m.group(1)) * 3600 int(m.group(2)) * 60 int(m.group(3)) pct min(99, round(current / duration_sec * 100)) print(f\r转码进度: {pct}%, end, flushTrue) proc.wait() print()解析out_time字符串是各版本 FFmpeg 都稳定的做法比解析out_time_ms更可靠因为那个字段的单位在不同版本里一会儿是毫秒一会儿是微秒踩过坑的人都懂。5. 常见问题排查与批量实战心得5.1 高频报错速查表跑多了之后大部分问题都是那几个老面孔。我整理了一份速查表基本覆盖了我批量处理几百个文件时遇到过的绝大多数故障。现象常见原因处理办法ffmpeg 不是内部或外部命令FFmpeg 未安装或不在 PATH重新安装完整版重启终端或代码里写死可执行文件全路径Unknown encoder libx265精简版 FFmpeg 没编译该编码器换完整构建版或改用 libx264height not divisible by 2输出分辨率出现奇数缩放表达式里用 -2强制偶数高度Invalid pixel format源素材是 10bit 或奇怪采样加 -pix_fmt yuv420p输出比原文件还大源视频本身码率不高或已被压缩过调高 CRF或脚本直接跳过此类文件压完没有声音音频参数写错或源文件音轨被丢弃检查 -c:a 参数确保没有误加 -an转换中途失败且无报错信息磁盘空间不足、进程被杀先检查剩余磁盘空间再检查报告里的错误信息5.2 三个容易被低估的工程坑第一别做无意义的压缩。不是所有视频都值得压比如一段本来就只有 1.5Mbps 码率的录屏视频再压一遍体积几乎不会变小反而损失画质。我的脚本里加了一行判断如果输出体积达到源文件的 95% 以上这文件基本等于白压后续直接标记为“跳过”或者换更高 CRF 重试。第二批量任务一定要“先试后跑”。用完整参数直接跑几百个文件如果某个参数设置有问题轻则白等几个小时重则批量产出一堆无法播放的文件。我现在的流程是先从任务里随便挑一个文件用-ss 0 -t 30截前 30 秒试压一版检查画面、声音、文件体积都符合预期之后再放开跑全量。第三给中断留后路。批量任务时长动辄数小时系统重启、断电、杀进程都可能打断任务。我的做法是输出文件先写成一个临时名成功转码后再 rename 成正式名字这样中途哪怕断掉重新跑一遍也只会补做没完成的部分不会留下一堆半截文件。说到这个想起一次让我印象很深的教训有一批视频忘记加-pix_fmt yuv420pFFmpeg 全程没报错但压出来的文件在几个播放器里全是黑屏。从那次之后我每次批量任务跑完都会再写一个小校验函数用 ffprobe 抽查几个输出文件的时长、大小和像素格式确认数据正常才算收工。5.3 我常用的实战参数模板最后分享几套我自己试用下来比较稳的参数模板可以直接抄作业。存档备份用途追求画质和体积的平衡-c:v libx264 -crf 23 -preset medium -pix_fmt yuv420p -c:a aac -b:a 128k -movflags faststart。上传网络平台体积优先先缩到 720p再用-crf 26 -preset veryfast速度又快体积控制也过得去。一批视频里既有手机拍的 4K 素材又有老旧的低清视频我就会让脚本先用 ffprobe 探测分辨率大的统一缩到 1920 宽度小的一律保持原分辨率只做重编码和音频统一。这套策略处理个人视频存档平均能把 800MB 左右的手机原片压到 180MB 上下画质损失在手机上看基本察觉不到。最后说一个自己反复踩坑才记住的习惯跑完一批后一定把报告落成 CSV文件名、压缩前后大小、压缩比、耗时、参数版本都记上。三个月后再回看你才知道当初为什么选这套参数哪些视频其实不该压。视频压缩这事本身不复杂难的是在没有人工盯的时候脚本还能稳定、可信地把整批任务跑完。参数、日志、可复现这三样补齐了你的压缩工具才算真正能交付。