前言
需求很朴素:把任意一段视频压到指定的 MB 数以内,比如 Discord 免费用户的 10 MB、Gmail 附件的 25 MB。
这个需求看着简单,但 FFmpeg 并没有 --target-size 25MB 这样的参数。CRF 模式是恒定质量、可变码率,你事先不知道输出多大;ABR 模式虽然能指定码率,但需要你自己把"目标大小"换算成码率,还要处理音频占用、容器开销这些细节。
这篇文章把整个链路讲透:码率反推公式 → 单遍 vs 两遍编码 → H.264/H.265 实测对比 → Python 自动化封装,最后附一份可直接跑的完整脚本。
环境准备
- Python 3.10+
- FFmpeg 6.0+(需在 PATH 中,
ffmpeg -version可验证) - 无需第三方 Python 库(只用标准库
subprocess/json)
bash
# macOS
brew install ffmpeg
# Ubuntu
sudo apt install ffmpeg
# Windows (winget)
winget install Gyan.FFmpeg
一、码率反推:核心公式
文件大小与码率的关系是线性的:
文件大小(bit) = 总码率(bps) × 时长(s)
换成实用单位(1 MB = 8 × 1024 × 1024 bit = 8388608 bit):
总码率(kbps) = 目标大小(MB) × 8192 ÷ 时长(s)
但直接用这个值会超标,因为还有两块开销没算:
- 音频码率:AAC 立体声通常 128 kbps,需要从总码率里扣掉
- 容器封装开销:MP4 的 moov box、每个 sample 的索引项等,一般占 1-3%
所以实际可用的视频码率是:
python
def calc_video_bitrate(target_mb: float, duration: float,
audio_kbps: int = 128,
overhead: float = 0.03) -> int:
"""根据目标文件大小反推视频码率(kbps)
Args:
target_mb: 目标文件大小,单位 MB
duration: 视频时长,单位秒
audio_kbps: 音频码率,单位 kbps
overhead: 容器封装开销比例,默认 3%
Returns:
视频码率(kbps),最低不低于 100
"""
total_kbps = target_mb * 8192 / duration # 总预算
total_kbps *= (1 - overhead) # 扣除容器开销
video_kbps = total_kbps - audio_kbps # 扣除音轨
return max(int(video_kbps), 100) # 兜底,防止负数
举个例子:把 252 秒的视频压到 25 MB。
python
>>> calc_video_bitrate(25, 252)
660
即视频码率 660 kbps,音频 128 kbps,理论输出约 24.3 MB。
用 ffprobe 拿到时长
python
import subprocess, json
def probe(path: str) -> dict:
"""用 ffprobe 读取视频元信息"""
cmd = [
"ffprobe", "-v", "error",
"-print_format", "json",
"-show_format", "-show_streams",
path
]
out = subprocess.run(cmd, capture_output=True, text=True, check=True)
info = json.loads(out.stdout)
video = next(s for s in info["streams"] if s["codec_type"] == "video")
return {
"duration": float(info["format"]["duration"]),
"size_mb": int(info["format"]["size"]) / 1024 / 1024,
"width": video["width"],
"height": video["height"],
"codec": video["codec_name"],
}
二、单遍 ABR vs 两遍编码
指定了码率还不够,编码器怎么分配这些码率也影响命中精度。
单遍 ABR(Average Bitrate)
bash
ffmpeg -i input.mp4 -c:v libx264 -b:v 660k -c:a aac -b:a 128k out.mp4
编码器边编边估算,开头的帧因为缺少全局信息,码率分配往往不准。实测偏差可能到 ±10%,运动剧烈的素材更差。
两遍编码(2-pass)
第一遍只做分析,把每帧的复杂度写进 log 文件;第二遍据此做全局最优分配。
bash
# Pass 1:只分析,不输出(-an 关闭音频加速分析)
ffmpeg -y -i input.mp4 -c:v libx264 -b:v 660k \
-pass 1 -an -f null /dev/null
# Pass 2:实际编码
ffmpeg -i input.mp4 -c:v libx264 -b:v 660k \
-pass 2 -c:a aac -b:a 128k out.mp4
Windows 下
/dev/null要换成NUL。
代价是编码时间翻倍,收益是命中精度提升到 ±2% 左右,并且同码率下画质更好(复杂片段能分到更多码率)。
结论:需要严格卡住文件大小时,必须用两遍编码。
为什么不用 CRF
CRF(Constant Rate Factor)是恒定质量模式,画质一致性最好,但输出大小完全不可控:
bash
ffmpeg -i input.mp4 -c:v libx264 -crf 23 out.mp4 # 输出多大?不知道
同样 CRF 23,一段 PPT 录屏可能输出 5 MB,一段滑雪视频可能输出 200 MB。CRF 适合"我要这个画质",ABR 两遍适合"我要这个大小",需求不同。
三、H.264 vs H.265 实测对比
用一段 1080p / 252 秒 / 原始 783 MB 的产品演示视频(含人物讲解 + 屏幕演示,运动量中等)测试,统一目标 25 MB,两遍编码。
测试命令:
bash
# H.264 (libx264)
ffmpeg -i in.mp4 -c:v libx264 -preset medium -b:v 660k -pass 2 \
-c:a aac -b:a 128k out_h264.mp4
# H.265 (libx265)
ffmpeg -i in.mp4 -c:v libx265 -preset medium -b:v 660k -x265-params pass=2 \
-c:a aac -b:a 128k out_h265.mp4
实测结果(测试机:i7-12700 / 无硬件加速):
| 编码器 | 目标 | 实际输出 | 偏差 | 编码耗时 | VMAF 分数 |
|---|---|---|---|---|---|
| libx264 (1-pass) | 25 MB | 27.4 MB | +9.6% | 1m48s | 78.2 |
| libx264 (2-pass) | 25 MB | 25.3 MB | +1.2% | 3m22s | 81.5 |
| libx265 (2-pass) | 25 MB | 24.9 MB | −0.4% | 9m47s | 87.1 |
VMAF 是 Netflix 开源的画质评估指标,满分 100,越高越接近原片。测量命令:
ffmpeg -i out.mp4 -i in.mp4 -lavfi libvmaf -f null -
几个可以确认的结论:
1. 同码率下 H.265 画质明显更好,VMAF 高出约 5.6 分。换个说法:要达到同样画质,H.265 大约只需要 H.264 六到七成的码率。这跟官方宣称的"节省 50%"有差距,实际收益取决于素材类型,静态画面收益更大。
2. H.265 编码耗时是 H.264 的近 3 倍 。这是纯软编的数据,如果启用硬件编码(hevc_nvenc / hevc_videotoolbox)能快一个数量级,但画质会掉一些。
3. 兼容性是 H.265 的最大问题 。Safari 和新版 Chrome 支持,但老设备、部分 Android 机型、微信内置浏览器仍有播放失败的风险。如果压出来是要发给不确定环境的人看,老老实实用 H.264。
四、完整脚本
把上面的逻辑封装成一个可直接用的命令行工具:
python
#!/usr/bin/env python3
"""compress.py --- 按目标文件大小压缩视频
用法:
python compress.py input.mp4 25 # 压到 25MB,H.264
python compress.py input.mp4 10 --hevc # 压到 10MB,H.265
python compress.py input.mp4 25 --scale 720
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
NULL_DEV = "NUL" if os.name == "nt" else "/dev/null"
def probe(path: str) -> dict:
"""读取视频元信息"""
cmd = ["ffprobe", "-v", "error", "-print_format", "json",
"-show_format", "-show_streams", path]
out = subprocess.run(cmd, capture_output=True, text=True, check=True)
info = json.loads(out.stdout)
video = next(s for s in info["streams"] if s["codec_type"] == "video")
return {
"duration": float(info["format"]["duration"]),
"size_mb": int(info["format"]["size"]) / 1024 / 1024,
"width": video["width"],
"height": video["height"],
}
def calc_video_bitrate(target_mb: float, duration: float,
audio_kbps: int = 128, overhead: float = 0.03) -> int:
"""反推视频码率(kbps)"""
total = target_mb * 8192 / duration * (1 - overhead)
return max(int(total - audio_kbps), 100)
def suggest_scale(bitrate_kbps: int, src_height: int) -> int:
"""根据码率推荐合理的输出高度
码率过低时必须同步降分辨率,否则每像素分到的数据太少会出现色块。
"""
if bitrate_kbps < 800:
target = 480
elif bitrate_kbps < 2000:
target = 720
elif bitrate_kbps < 5000:
target = 1080
else:
return src_height # 码率充足,保持原分辨率
return min(target, src_height) # 不做放大
def compress(src: str, target_mb: float, hevc: bool = False,
scale: int | None = None, audio_kbps: int = 128) -> str:
meta = probe(src)
vb = calc_video_bitrate(target_mb, meta["duration"], audio_kbps)
height = scale or suggest_scale(vb, meta["height"])
encoder = "libx265" if hevc else "libx264"
dst = str(Path(src).with_name(f"{Path(src).stem}_{int(target_mb)}mb.mp4"))
print(f"[info] 源文件 {meta['size_mb']:.1f}MB / {meta['duration']:.0f}s "
f"/ {meta['width']}x{meta['height']}")
print(f"[info] 目标 {target_mb}MB → 视频码率 {vb}kbps / 输出高度 {height}p")
# 缩放滤镜:宽度自动取偶数(编码器要求)
vf = f"scale=-2:{height}"
# ---- Pass 1 ----
p1 = ["ffmpeg", "-y", "-i", src, "-c:v", encoder, "-b:v", f"{vb}k",
"-vf", vf, "-preset", "medium"]
p1 += (["-x265-params", "pass=1"] if hevc else ["-pass", "1"])
p1 += ["-an", "-f", "null", NULL_DEV]
print("[1/2] 分析中...")
subprocess.run(p1, check=True, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
# ---- Pass 2 ----
p2 = ["ffmpeg", "-y", "-i", src, "-c:v", encoder, "-b:v", f"{vb}k",
"-vf", vf, "-preset", "medium"]
p2 += (["-x265-params", "pass=2"] if hevc else ["-pass", "2"])
p2 += ["-c:a", "aac", "-b:a", f"{audio_kbps}k",
"-movflags", "+faststart", dst]
print("[2/2] 编码中...")
subprocess.run(p2, check=True, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
# 清理两遍编码产生的 log
for f in Path(".").glob("ffmpeg2pass*"):
f.unlink(missing_ok=True)
for f in Path(".").glob("x265_2pass*"):
f.unlink(missing_ok=True)
out_mb = Path(dst).stat().st_size / 1024 / 1024
ratio = (1 - out_mb / meta["size_mb"]) * 100
print(f"[done] {dst} --- {out_mb:.2f}MB (压缩 {ratio:.1f}%)")
return dst
def main():
ap = argparse.ArgumentParser(description="按目标大小压缩视频")
ap.add_argument("input", help="输入视频路径")
ap.add_argument("target", type=float, help="目标大小(MB)")
ap.add_argument("--hevc", action="store_true", help="使用 H.265 编码")
ap.add_argument("--scale", type=int, help="强制输出高度,如 720")
ap.add_argument("--audio", type=int, default=128, help="音频码率(kbps)")
args = ap.parse_args()
if not Path(args.input).exists():
sys.exit(f"文件不存在: {args.input}")
try:
compress(args.input, args.target, args.hevc, args.scale, args.audio)
except subprocess.CalledProcessError as e:
sys.exit(f"FFmpeg 执行失败: {e}")
if __name__ == "__main__":
main()
运行效果
$ python compress.py demo.mp4 25
[info] 源文件 783.2MB / 252s / 1920x1080
[info] 目标 25.0MB → 视频码率 660kbps / 输出高度 480p
[1/2] 分析中...
[2/2] 编码中...
[done] demo_25mb.mp4 --- 25.31MB (压缩 96.8%)
注意脚本自动把分辨率降到了 480p------因为 660 kbps 配 1080p 会严重色块化。如果你不接受这个降级,只能提高目标大小,这是物理约束,不是工具问题。
五、什么时候不该自己写脚本
上面这套方案的适用前提是:你有 FFmpeg 环境、能跑 Python、且要批量处理。
如果是下面这些情况,脚本反而是负担:
- 偶尔压一两个文件:装 FFmpeg + 调参数的时间,比压缩本身长得多
- 要给非技术同事用:你没法让运营同学跑命令行
- 在没有开发环境的机器上(客户现场、iPad、临时借用的电脑)
这时候浏览器方案更实际。我平时应急会用 VideoCompress 这类在线工具,它的交互逻辑跟本文的思路一致------直接填目标 MB 数,服务端去做码率反推和编码,省掉了自己算的步骤;也提供高级模式手动调清晰度和画面尺寸。支持 40 多种输入格式,导出不带水印,另外附带裁剪、剪辑、转 MP3、视频转文字这些配套功能。
选型时要注意的边界,跟所有云端方案一样:
| 维度 | 本地 FFmpeg | 在线工具 |
|---|---|---|
| 文件上限 | 无 | 有(VideoCompress 免费 1GB / 付费 10GB) |
| 隐私 | 文件不出本机 | 需上传(其说明为完成后 24h 内自动删除) |
| 批量自动化 | 强,可脚本化 | 弱 |
| 环境依赖 | 需安装配置 | 浏览器即可 |
| 参数可控性 | 完全可控 | 受界面暴露的选项限制 |
涉密素材、需要接进 CI/CD 的流程,用本地方案;一次性任务、临时环境,用在线工具。没必要非此即彼。
六、几个工程实践上的坑
1. -movflags +faststart 别忘了加。 它把 moov box 移到文件开头,让视频能边下边播。不加的话,网页上播放要等整个文件下载完,体验差很多。
2. 别对已经压过的视频二次压缩。 有损编码是不可逆的,二次压缩是在已经丢失信息的基础上继续丢,画质衰减速度远快于体积缩减。管线设计上要保证始终从母版素材出发。
3. 先剪辑再压缩。 文件大小和时长成正比。剪掉 30% 的无效片段,比在码率上死磕划算得多,而且不损失保留部分的画质。
4. 分辨率和码率必须匹配。 这是最常见的错误------保持 1080p 硬压到极低码率,结果满屏色块。宁可降到 720p 保住码率密度。
5. 两遍编码的 log 文件会污染工作目录。 ffmpeg2pass-0.log、x265_2pass.log 这些要记得清理,并发跑多个任务时还要用 -passlogfile 指定不同路径,否则会互相覆盖。
总结
按目标大小压缩视频,核心就三步:
ffprobe拿时长 → 用目标MB × 8192 ÷ 时长 − 音频码率反推视频码率- 根据码率同步下调分辨率,保证每像素码率密度
- 用两遍 ABR 编码精确命中目标
编码器选择上,追求体积效率和画质用 H.265,追求兼容性和编码速度用 H.264。要严格卡大小就别用 CRF。
工具选择上没有唯一答案------批量、涉密、要自动化就用 FFmpeg;一次性、无环境、给非技术同事用就走在线工具。搞清楚约束条件比纠结工具重要。
参考资料
标签:视频压缩、FFmpeg、H.265、音视频开发、Python自动化