第一次做 HLS 多码率的时候,我做了件很自然的事:去网上找一张"推荐码率阶梯表"。这类表到处都是(Apple 的 HLS 作者指南里有一份,各云厂商的文档里也都有),大致长这样:
档位 分辨率 码率 1080p 1920×1080 4.5 Mbps 720p 1280×720 2.5 Mbps 480p 854×480 1.2 Mbps 360p 640×360 0.6 Mbps 我照着生成了四档,交付,客户说"能播"。但过了一个月看带宽账单,发现有不对劲的地方:同一批内容里,动画那部分用 4.5M 明显浪费(用 2M 也看不出区别),而赛事那部分 4.5M 根本不够(快动作全是块)。
后来我改成了 per-title 编码:先测这个内容有多难压缩,再决定每一档给多少码率。改完之后总带宽降了 31%,而且难压的内容画质反而变好了。这篇写完整的做法。
TL;DR:码率阶梯不能套模板,因为"内容复杂度"差异巨大(动画和赛事能差 5 倍)。做法是先量化复杂度(CRF 试探编码,或算 SI/TI),再为每个分辨率档找到"达到目标质量的最低码率"。三个必须注意的技术点:ABR 场景必须用
-b:v -maxrate -bufsize控制码率(不能用 CRF,因为要可预测的带宽);所有档位必须关键帧对齐(GOP 相同、关键帧时间戳一致,否则切换时会卡顿或花屏);master playlist 里的 BANDWIDTH 要写实际值(写小了客户端会选错档导致缓冲)。
目录
- 一、ABR 到底在解决什么
- 二、为什么通用阶梯表不灵
- 三、先量化:这个内容有多难压
- 四、per-title 的完整流程
- 五、关键帧对齐:最容易翻车的地方
- 六、VBV:别让码率抖爆客户端缓冲
- 七、HLS 打包与 master playlist
- 八、验证:怎么确认阶梯是对的
- 九、实测数据
- 十、坑清单
一、ABR 到底在解决什么
用户的网络是波动的(地铁里、Wi-Fi 切换、晚高峰)。如果只提供一档码率:
- 码率高了 → 用户带宽不够 → 一直缓冲;
- 码率低了 → 带宽好的用户看到糊画面。
ABR(Adaptive Bitrate)的解法:把同一内容编码成多档(rendition),每档切成同样长度的小分片(比如 6 秒),客户端根据实测带宽在分片边界切换档位。
所以 ABR 对编码有两个特殊要求,这是它和普通转码最大的区别:
| 要求 | 原因 |
|---|---|
| 每档码率必须可预测且受限 | 客户端按 BANDWIDTH 估算"我能不能下完这个分片",码率飙上去就会缓冲 |
| 所有档的关键帧必须对齐 | 切换发生在分片边界,如果那个位置在其他档不是关键帧,就无法干净地切 |
这两条是 ABR 编码的全部难点,其他(分辨率、帧率)都好办。
二、为什么通用阶梯表不灵
那张表不是错的,它是面向"平均内容"的。问题在于内容之间的差异远超"平均"这个概念。
我实测过同一批素材在 1080p、目标 VMAF 93 时需要的码率:
| 内容 | 需要的码率 | 相对值 |
|---|---|---|
| 动画(大面积纯色 + 线条) | 1.4 Mbps | 1.0× |
| 屏录(PPT、静态为主) | 0.9 Mbps | 0.6× |
| 室内访谈(人物中景、虚化背景) | 2.6 Mbps | 1.9× |
| 街头纪录片(中等运动) | 4.1 Mbps | 2.9× |
| 足球赛事(高速运动、草地细节) | 6.8 Mbps | 4.9× |
| 夜景(高噪点) | 7.5 Mbps | 5.4× |
最难的比最容易的贵 8 倍。 用同一个 4.5M:
- 动画:浪费 3 Mbps(68%);
- 夜景:不够(差 40%),画面全是块和涂抹。
这就是套模板的代价:要么浪费带宽,要么画质不达标,而且往往是同时发生(因为一批内容里什么类型都有)。
三、先量化:这个内容有多难压
per-title 的第一步是给内容打一个"复杂度分"。两种办法:
办法 1:试探编码(最准)
用固定 CRF 编一小段(或者整片),看实际输出码率:
# 用目标分辨率 + 目标 preset,固定 CRF 试编码
ffmpeg -i input.mp4 -vf scale=1920:1080 -c:v libx264 -crf 23 -preset medium \
-an -f null - 2>&1 | tail -3
# 或者真的输出文件看大小
ffmpeg -i input.mp4 -vf scale=1920:1080 -c:v libx264 -crf 23 -preset medium \
-an -t 60 probe.mp4
BITRATE=$(ffprobe -v error -show_entries format=bit_rate -of csv=p=0 probe.mp4)
CRF 23 输出的码率越低,说明内容越好压。这个方法的好处是"用你真实要用的编码器和参数测",结果直接可用。
办法 2:SI/TI 指标(快,不用编码)
SI(Spatial Information,空间细节)和 TI(Temporal Information,时间变化)是 ITU-T P.910 定义的客观指标:
- SI 高 → 画面细节多(纹理、噪点)→ 难压;
- TI 高 → 运动剧烈 → 难压。
import cv2
import numpy as np
def calc_si_ti(video_path: str, sample_frames: int = 60):
"""抽样计算 SI/TI。SI=帧内 Sobel 标准差,TI=相邻帧差的标准差。"""
cap = cv2.VideoCapture(video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
step = max(1, total // sample_frames)
prev_gray = None
sis, tis = [], []
idx = 0
while True:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = cap.read()
if not ok:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# SI:Sobel 梯度的标准差
sobel = cv2.Sobel(gray, cv2.CV_64F, 1, 1, ksize=5)
sis.append(np.std(sobel))
# TI:与上一帧差的标准差
if prev_gray is not None:
tis.append(np.std(gray.astype(np.float64) - prev_gray.astype(np.float64)))
prev_gray = gray
idx += step
if idx >= total:
break
cap.release()
return float(np.mean(sis)), float(np.mean(tis) or 0)
实测的参考值:
| 内容 | SI | TI | 判断 |
|---|---|---|---|
| 动画 | 18 | 6 | 很好压 |
| 访谈 | 32 | 9 | 中等 |
| 赛事 | 55 | 31 | 难压 |
| 夜景 | 61 | 14 | 难压(SI 主导) |
SI/TI 的好处是快(不用编码,几十秒出结果),适合做批量预处理;缺点是它是客观指标,跟"主观质量需要多少码率"不是线性关系。
我的做法:SI/TI 做粗分档(把内容分成 3~4 档复杂度),每档用试探编码标定一次实际码率,之后同档的内容复用这个标定值。这样既准又快。
四、per-title 的完整流程
1. 分析源(分辨率、帧率、时长、SI/TI)
↓
2. 确定这一内容需要哪些档位(分辨率阶梯)
↓
3. 对每个档位:跑 3~4 个候选码率 → 测 VMAF → 找到"达到目标 VMAF 的最低码率"
↓
4. 用选出的码率正式编码(带 VBV 限制)
↓
5. 打包 HLS/DASH(保证关键帧对齐)
↓
6. 验证(每档关键帧位置一致、实际码率符合、BANDWIDTH 正确)
第 3 步的实现
对每个分辨率档,用几个候选码率编码短片段(60 秒就够),测 VMAF:
import subprocess
import json
def probe_bitrate_for_quality(src, width, height, target_vmaf=93.0,
candidates=(800, 1200, 1800, 2500, 3500, 5000, 7000)):
"""找到达到目标 VMAF 的最低码率(kbps)。找不到返回最大的。"""
scale = f'scale={width}:{height}'
best = None
for kbps in candidates:
out = f'/tmp/probe_{width}x{height}_{kbps}.mp4'
subprocess.run([
'ffmpeg', '-y', '-v', 'error', '-i', src,
'-vf', f'{scale},fps=30,setpts=PTS-STARTPTS',
'-c:v', 'libx264', '-b:v', f'{kbps}k',
'-maxrate', f'{int(kbps * 1.3)}k', '-bufsize', f'{int(kbps * 2)}k',
'-preset', 'medium', '-an', '-t', '60', out,
], check=True)
vmaf = run_vmaf(out, src_scaled_reference) # 见前面的 VMAF 篇
print(f' {width}x{height} @ {kbps}k -> VMAF {vmaf:.1f}')
if vmaf >= target_vmaf:
best = kbps
break # 候选是从小到大,第一个达标即最低
return best or candidates[-1]
注意参考源:测 VMAF 时参考源必须是缩放到同分辨率后的源(不能拿 4K 源跟 720p 输出比)。所以要先准备一份缩放后的参考:
ffmpeg -y -i src.mp4 -vf scale=1280:720:fps=30:flags=lanczos -an -c:v libx264 -crf 12 ref_720p.mp4
实测:同一内容的两种阶梯
对一段足球赛事(1080p 源):
| 档位 | 通用模板给的码率 | per-title 算出的码率 | 差异 |
|---|---|---|---|
| 1080p | 4500 k | 6800 k | +51%(不加就糊) |
| 720p | 2500 k | 3400 k | +36% |
| 480p | 1200 k | 1150 k | -4% |
| 360p | 600 k | 520 k | -13% |
对一段动画(1080p 源):
| 档位 | 通用模板 | per-title | 差异 |
|---|---|---|---|
| 1080p | 4500 k | 1400 k | -69% |
| 720p | 2500 k | 850 k | -66% |
| 480p | 1200 k | 480 k | -60% |
| 360p | 600 k | 280 k | -53% |
动画省了近 70% 带宽,赛事加了 50% 但画质达标。 这就是 per-title 的价值——它不是一味省钱,而是"把带宽花在需要的地方"。
什么时候不值得做 per-title
per-title 的代价是每个内容都要多次试探编码(成本增加)。判断标准:
- 内容量大、长期存储、播放量大(比如教育课程库、影视库)→ 值得,带宽节省几个月就回本;
- 一次性的、播放量小的(比如内部会议录像)→ 不值得,用按类型分档的"准 per-title"(把内容分 3~4 类,每类一套阶梯)就够了。
我们最后的折中:按 SI/TI 把内容分成"简单/中等/复杂/极复杂"四类,每类维护一套阶梯,每季度用试探编码校准一次。这比逐片 per-title 省事,比一套模板准得多。
五、关键帧对齐:最容易翻车的地方
这条如果做错,ABR 切换时会卡顿、花屏,甚至播放失败。
为什么需要对齐:客户端在分片边界切换档位。假设第 10 个分片开始切到 720p,那么 720p 流的第 10 个分片也必须是从关键帧开始的同一段内容。如果 720p 在那个时间点不是关键帧,客户端拿到的分片没有 IDR 帧,解码器就得从前面的 P 帧开始——而它已经不下载那些了,结果就是花屏或者卡住。
必须保证:
- 所有档位 GOP 长度相同(时间上);
- 关键帧出现的时间点完全一致;
- 分片边界恰好落在关键帧上。
怎么做
# 关键:固定 GOP,关闭场景切换自适应关键帧
ffmpeg -i input.mp4 \
-vf scale=1920:1080 \
-c:v libx264 -preset slow -profile:v high -level 4.1 \
-b:v 4500k -maxrate 5400k -bufsize 9000k \
-g 60 -keyint_min 60 -sc_threshold 0 \ # ← 关键三行
-c:a aac -b:a 128k -ar 48000 \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_filename '1080p_%03d.ts' 1080p.m3u8
参数解释:
| 参数 | 作用 |
|---|---|
-g 60 |
GOP = 60 帧(30fps 下 = 2 秒) |
-keyint_min 60 |
最小关键帧间隔也设成 60,防止编码器自己插入 |
-sc_threshold 0 |
关闭场景切换检测(否则遇到镜头切换会插关键帧,破坏对齐) |
-hls_time 6 |
每片 6 秒(必须是 GOP 时长的整数倍:6 = 2 × 3) |
-sc_threshold 0 是最容易忘的。x264 默认会在场景切换处插入关键帧(这是好事,提升随机访问),但它破坏了 ABR 对齐。所以 ABR 场景必须关掉。
GOP 时长的选择:
| GOP | 优点 | 缺点 |
|---|---|---|
| 2 秒 | 切换快(起播快、切档延迟小) | 码率效率低(关键帧多、文件大) |
| 4 秒 | 折中 | |
| 6~10 秒 | 压缩效率高 | 切换迟钝、起播慢 |
点播(VOD)用 4~6 秒,直播用 2~4 秒(低延迟要求)。我们点播用 4 秒(GOP=帧率×4)。
用强制关键帧的方式(更保险)
如果怕编码器不听话,可以直接指定关键帧时间点:
ffmpeg -i input.mp4 \
-force_key_frames 'expr:gte(t,n_forced*4)' \ # 每 4 秒一个关键帧
...
这个表达式的意思是"当时间 ≥ 第 n 个强制关键帧 × 4 秒时插入关键帧"——绝对精确,不依赖帧数换算(对 VFR 源也安全)。
六、VBV:别让码率抖爆客户端缓冲
ABR 场景下,客户端按 BANDWIDTH 估算下载时间。如果某个分片实际码率远超声明值,客户端来不及下完 → 缓冲。
VBV(Video Buffering Verifier)就是用来限制码率波动的:
-maxrate 5400k -bufsize 9000k
maxrate:任何时间窗口内的峰值码率上限;bufsize:缓冲区大小(决定"允许多久的高码率"),通常设为maxrate × 2。
经验值:
b:v = 目标平均码率
maxrate = b:v × 1.2 ~ 1.5
bufsize = maxrate × 2
bufsize 太小 → 编码器被约束得太死,复杂场景画质下降明显(因为不敢给码率);
bufsize 太大 → 等于没限制,客户端缓冲风险回升。
这也是为什么 ABR 不能用 CRF:CRF 是"保证质量、码率随便",在赛事高潮段可能冲到 15 Mbps,客户端按 4.5 Mbps 估算带宽,必然缓冲。
但可以用"CRF + maxrate"的组合(两遍编码里的 CRF 模式也支持 VBV):
# CRF 决定质量,maxrate 兜底(适合不严格要求带宽上限的场景)
-c:v libx264 -crf 23 -maxrate 6000k -bufsize 12000k
这在"存储为主、ABR 为辅"的场景能用。纯 ABR 分发还是用 -b:v 更可控。
七、HLS 打包与 master playlist
多档编码完成后,写一个 master playlist:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=6800000,AVERAGE-BANDWIDTH=5500000,RESOLUTION=1920x1080,CODECS="avc1.640029,mp4a.40.2"
1080p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3400000,AVERAGE-BANDWIDTH=2800000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1150000,AVERAGE-BANDWIDTH=950000,RESOLUTION=854x480,CODECS="avc1.64001f,mp4a.40.2"
480p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=520000,AVERAGE-BANDWIDTH=430000,RESOLUTION=640x360,CODECS="avc1.640015,mp4a.40.2"
360p.m3u8
几个容易错的点:
BANDWIDTH必须 ≥ 该档的峰值码率(含音频和容器开销),不能只写视频码率。我一般写视频 maxrate + 音频码率再上浮 10%;AVERAGE-BANDWIDTH:如果写了,客户端会用它做长期决策(更准)。建议写;CODECS里的avc1.640029:这是 H.264 的 profile/level 编码(64= high,0029= level 4.1)。写错了某些播放器会直接拒绝播放。可以用 ffprobe 拿准确值:
ffprobe -v error -select_streams v:0 -show_entries stream=codec_tag_string,profile,level -of csv=p=0 out.mp4
RESOLUTION要写实际分辨率(缩之后可能不是整数,比如 854×480)。
用 ffmpeg 一次生成多档 + master(适合简单场景):
ffmpeg -i input.mp4 \
-map 0:v -map 0:a -map 0:v -map 0:a -map 0:v -map 0:a \
-c:v libx264 -c:a aac -ar 48000 \
-b:v:0 4500k -maxrate:0 5400k -bufsize:0 9000k -s:v:0 1920x1080 -profile:v:0 high -level:0 4.1 \
-b:v:1 2500k -maxrate:1 3000k -bufsize:1 5000k -s:v:1 1280x720 -profile:v:1 main -level:1 3.1 \
-b:v:2 1200k -maxrate:2 1440k -bufsize:2 2400k -s:v:2 854x480 -profile:v:2 main -level:2 3.1 \
-g 120 -keyint_min 120 -sc_threshold 0 \
-f hls -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
-hls_time 4 -hls_playlist_type vod \
-master_pl_name master.m3u8 \
-hls_segment_filename 'v%v_%03d.ts' 'v%v.m3u8'
-var_stream_map 是 ffmpeg 的变体流映射,一次输出多档 + master playlist。但这个方式不能做 per-title(每档码率是写死的)——per-title 需要分多次编码,再手写 master playlist。
八、验证:怎么确认阶梯是对的
交付前必做的四项检查:
1. 关键帧是否对齐
for f in 1080p.m3u8 720p.m3u8 480p.m3u8; do
echo "== $f"
ffprobe -v error -select_streams v:0 -skip_frame nokey -show_frames \
-show_entries frame=pts_time -of csv=p=0 "$f" | head -5
done
输出的 pts_time 序列必须完全一致(0.0、4.0、8.0、12.0…)。差一点都不行。
2. 实际码率是否符合声明
for f in 1080p.m3u8; do
ffprobe -v error -show_entries format=bit_rate -of csv=p=0 "$f"
done
实测值应该 ≤ 声明的 BANDWIDTH。如果超了,调低 b:v 或者把声明值改大。
3. 分片时长是否均匀
grep '#EXTINF' 1080p.m3u8 | head -10
# 应该都是 4.000000(除了最后一片)
4. 真实播放器切换测试
用 VLC 或者 hls.js 的 demo 页面,手动限制带宽(Chrome DevTools 的 Network throttling),观察:
- 能不能自动降档;
- 降档时有没有卡顿/花屏;
- 恢复带宽后能不能升回去。
这一步不能省。我交付前会在 3 种网络条件下各跑一遍(快/中/慢),并且重点看"切换的那一瞬间"——关键帧没对齐的问题只在那一瞬间暴露。
九、实测数据
对 42 个视频(混合内容)做的对比:
| 指标 | 通用模板阶梯 | per-title 阶梯 | 变化 |
|---|---|---|---|
| 总存储占用 | 100%(基线) | 69% | -31% |
| 平均 VMAF(各档加权) | 91.2 | 93.4 | +2.2 |
| 最低档 VMAF(最差的那个内容) | 82.1(不达标) | 92.8 | 达标 |
| 客户端缓冲次数(实测 100 次播放) | 7 | 2 | -71% |
最关键的改善是最低分从 82 提到了 92——也就是"最难压的那个内容不再糊了"。通用模板的问题不只是浪费,更是对困难内容不公平。
编码耗时:per-title 每个内容多花约 3 分钟(试探编码)。42 个内容多花 2 小时,换来的是长期 31% 的带宽节省——对长期存储的内容,这笔账很好算。
十、坑清单
- 直接套用网上的码率阶梯表 → 动画浪费 70%、赛事糊。按内容定。
- ABR 用 CRF 编码 → 码率不可控,峰值冲高导致客户端缓冲。用
-b:v+ VBV。 - 没设
maxrate/bufsize→ 复杂场景码率飙到 3 倍,客户端缓冲。 - 忘了
-sc_threshold 0→ 场景切换处插关键帧,破坏对齐,切换时花屏。 - GOP 各档不一致 → 关键帧时间戳对不上。所有档用同一个
-g。 hls_time不是 GOP 的整数倍 → 分片边界不在关键帧上,切片被强制延长/切碎。- master playlist 的
BANDWIDTH只写了视频码率 → 没算音频和容器开销,客户端低估,缓冲。 CODECS里的 profile/level 写错 → 播放器拒绝播放。用 ffprobe 拿准确值。- 不做关键帧对齐验证 → 上线后才发现切换花屏。
- VMAF 对比时参考源没缩放 → 4K 源和 720p 输出比,分数没有意义。
- 所有内容用同一套阶梯 → per-title 的意义就是每个内容都不同。至少按复杂度分类。
- 档位太多 → 每档都要编码和存储,收益递减。4~5 档就够了,相邻档码率差 1.6~2 倍比较合适。
- 档位之间码率差距太小 → 客户端频繁切换(码率差 < 40% 时切换收益不明显)。
- 音频每档重复编码 → 浪费。音频只用一档(128k),所有视频档共用,或者在 master 里单独声明音轨。
- 不考虑目标设备的分辨率上限 → 给只支持 720p 的设备做了 1080p 档,白白浪费存储。按用户设备分布决定最高档。
最后说点体会。
per-title 编码听起来很"高级",但它的核心思想其实特别朴素:不要用平均值去对待差异巨大的个体。这在工程里是普遍适用的——平均响应时间掩盖了 P99 的长尾,平均文件大小掩盖了那几个 20GB 的怪物,平均码率掩盖了"动画很省、赛事很贵"。
我第一次看到那张"内容复杂度差 8 倍"的表时,就意识到所有"一套参数打天下"的做法都是错的。从那以后我养成了一个习惯:做任何"给一批东西定参数"的工作之前,先测一下这批东西的分布。测完往往会发现——根本不存在一个通用值,要么分档,要么逐个算。
成本上,分档(3~4 类)是性价比最高的:它拿到了 per-title 大约 80% 的收益,成本只有 20%。真正需要逐片 per-title 的,是那种"存储几年、播放几百万次"的内容。