提示

返回博客列表

自适应码率阶梯怎么设计:从一套"万能参数"到 per-title 编码

第一次做 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 到底在解决什么

用户的网络是波动的(地铁里、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 帧开始——而它已经不下载那些了,结果就是花屏或者卡住。

必须保证:

  1. 所有档位 GOP 长度相同(时间上);
  2. 关键帧出现的时间点完全一致;
  3. 分片边界恰好落在关键帧上。

怎么做

# 关键:固定 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

几个容易错的点:

  1. BANDWIDTH 必须 ≥ 该档的峰值码率(含音频和容器开销),不能只写视频码率。我一般写 视频 maxrate + 音频码率 再上浮 10%;
  2. AVERAGE-BANDWIDTH:如果写了,客户端会用它做长期决策(更准)。建议写;
  3. 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
  1. 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% 的带宽节省——对长期存储的内容,这笔账很好算。

十、坑清单

  1. 直接套用网上的码率阶梯表 → 动画浪费 70%、赛事糊。按内容定。
  2. ABR 用 CRF 编码 → 码率不可控,峰值冲高导致客户端缓冲。用 -b:v + VBV。
  3. 没设 maxrate/bufsize → 复杂场景码率飙到 3 倍,客户端缓冲。
  4. 忘了 -sc_threshold 0 → 场景切换处插关键帧,破坏对齐,切换时花屏。
  5. GOP 各档不一致 → 关键帧时间戳对不上。所有档用同一个 -g。
  6. hls_time 不是 GOP 的整数倍 → 分片边界不在关键帧上,切片被强制延长/切碎。
  7. master playlist 的 BANDWIDTH 只写了视频码率 → 没算音频和容器开销,客户端低估,缓冲。
  8. CODECS 里的 profile/level 写错 → 播放器拒绝播放。用 ffprobe 拿准确值。
  9. 不做关键帧对齐验证 → 上线后才发现切换花屏。
  10. VMAF 对比时参考源没缩放 → 4K 源和 720p 输出比,分数没有意义。
  11. 所有内容用同一套阶梯 → per-title 的意义就是每个内容都不同。至少按复杂度分类。
  12. 档位太多 → 每档都要编码和存储,收益递减。4~5 档就够了,相邻档码率差 1.6~2 倍比较合适。
  13. 档位之间码率差距太小 → 客户端频繁切换(码率差 < 40% 时切换收益不明显)。
  14. 音频每档重复编码 → 浪费。音频只用一档(128k),所有视频档共用,或者在 master 里单独声明音轨。
  15. 不考虑目标设备的分辨率上限 → 给只支持 720p 的设备做了 1080p 档,白白浪费存储。按用户设备分布决定最高档。

最后说点体会。

per-title 编码听起来很"高级",但它的核心思想其实特别朴素:不要用平均值去对待差异巨大的个体。这在工程里是普遍适用的——平均响应时间掩盖了 P99 的长尾,平均文件大小掩盖了那几个 20GB 的怪物,平均码率掩盖了"动画很省、赛事很贵"。

我第一次看到那张"内容复杂度差 8 倍"的表时,就意识到所有"一套参数打天下"的做法都是错的。从那以后我养成了一个习惯:做任何"给一批东西定参数"的工作之前,先测一下这批东西的分布。测完往往会发现——根本不存在一个通用值,要么分档,要么逐个算。

成本上,分档(3~4 类)是性价比最高的:它拿到了 per-title 大约 80% 的收益,成本只有 20%。真正需要逐片 per-title 的,是那种"存储几年、播放几百万次"的内容。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部