自建视频分发,从决定不用托管方案那一刻起,第一个要拍板的问题不是 CDN 选哪家,而是 HLS 分片用什么容器:fMP4 还是 TS。先说结论,不展开定义:
- 选 fMP4:它是 CMAF 标准化的容器方案,HLS 和 DASH 可以共享同一份分片;头信息只写进一个 init segment,不在每个分片里重复;以后想往 LL-HLS 走,fMP4 也是那条路的地基。目标是 hls.js、AVPlayer、ExoPlayer 这类现代播放端的新项目,选它。
- 选 TS:老机顶盒、老智能电视、一切"HLS 就是 TS"年代的存量设备。另外,TS 分片自带解码信息,单个分片可以独立播放和检查,运维排查省一步。兼容旧终端是硬需求时,选它。
两个都要?下面的代码改一个枚举值就能切换。本文用 Rust crate ez-ffmpeg(别和 JS 那个 "Ez FFmpeg" 混淆,两者无关)在进程内跑通 HLS 打包:先单码率 fMP4,再双码率 ABR 阶梯,最后讲两个 0.14 里修过的坑。全部代码在 FFmpeg 7.1.3 + ez-ffmpeg 0.15 环境下编译、运行,产物都逐个用 ffprobe 检查过;文中贴的输出都是真实运行结果,一处没编。
先交代边界:装 FFmpeg、链接 FFmpeg 的麻烦,这个库不解决------它底层就是 libav 系库,FFmpeg 7.1--8.x 该装还得装。它省掉的是链接成功后的那些工作:手写 demux/decode/filter/encode/mux 流水线(pipeline),或者在生产环境里塞一个 Command::new("ffmpeg") 再解析 stderr。
最短可跑:单码率 fMP4
toml
[dependencies]
ez-ffmpeg = "0.15" # Rust >= 1.80,FFmpeg 7.1-8.x
rust
use ez_ffmpeg::{FfmpegContext, Output};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// The hls muxer writes into an existing directory; it does not mkdir by default.
std::fs::create_dir_all("hls_single")?;
FfmpegContext::builder()
.input("input.mp4")
.output(
Output::from("hls_single/index.m3u8")
.set_format("hls") // -f hls
.set_video_codec("libx264")
.set_audio_codec("aac")
// Fixed 6 s GOP at 30 fps, no scene-cut keyframes: segment
// boundaries can only land on keyframes, so pin them down.
.set_video_codec_opt("g", "180")
.set_video_codec_opt("sc_threshold", "0")
.set_format_opt("hls_time", "6") // target segment length
.set_format_opt("hls_playlist_type", "vod") // full playlist, ENDLIST at the end
.set_format_opt("hls_segment_type", "fmp4") // .m4s instead of .ts
.set_format_opt("hls_fmp4_init_filename", "init.mp4")
.set_format_opt("hls_segment_filename", "hls_single/seg_%05d.m4s"),
)
.build()?
.start()?
.wait()?;
println!("wrote hls_single/index.m3u8");
Ok(())
}
输入是一段 lavfi 生成的 12 秒测试视频(640x360、30fps、H.264+AAC)。运行结束后 hls_single/ 里会有四个文件:index.m3u8、init.mp4、seg_00000.m4s、seg_00001.m4s。播放列表逐字如下:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:6.000000,
seg_00000.m4s
#EXTINF:6.000000,
seg_00001.m4s
#EXT-X-ENDLIST
12 秒恰好切成两个 6.000000 秒分片,EXT-X-MAP 指向唯一的 init.mp4------这就是 fMP4 HLS 的结构特征。#EXT-X-VERSION:7 无需额外处理:EXT-X-MAP 要求协议版本 6+,muxer 自己写成 7。
参数怎么对上(以及笔者最初走错的那一步)
平时敲 CLI 的话,映射关系可以逐行对应:
| ffmpeg CLI | ez-ffmpeg | 说明 |
|---|---|---|
-f hls |
.set_format("hls") |
选 hls muxer |
-hls_time 6 |
.set_format_opt("hls_time", "6") |
目标分片时长,只能落在关键帧 |
-hls_playlist_type vod |
.set_format_opt("hls_playlist_type", "vod") |
完整列表 + ENDLIST |
-hls_segment_type fmp4 |
.set_format_opt("hls_segment_type", "fmp4") |
不设即 TS |
-hls_fmp4_init_filename init.mp4 |
.set_format_opt("hls_fmp4_init_filename", "init.mp4") |
init segment 文件名 |
-hls_segment_filename ... |
.set_format_opt("hls_segment_filename", "...") |
分片命名模板 |
-g 180 -sc_threshold 0 |
.set_video_codec_opt("g", "180") 等 |
编码器参数,走 codec_opt |
规则与本系列 cookbook 篇一致:CLI 参数名去掉 - 原样可用,容器参数走 set_format_opt,编码器参数走 set_video_codec_opt,底层都是 FFmpeg 自己的 AVOption 系统。
那两行 GOP 参数,是笔者在第一版里遗漏的。不设 g,分片时长跑出来是这样:
#EXTINF:8.333333,
seg_00000.m4s
#EXTINF:3.666667,
seg_00001.m4s
hls_time=6 是目标 ,不是死命令:muxer 只能在关键帧处切,而 x264 默认 keyint 250,30fps 下第一个可切点在 8.33 秒------于是你要的 6 秒,实际就会变成 8.33+3.67。要让切点真正落在 6 秒,就让关键帧恰好每 6 秒一个:g = 6 x 30 = 180,再加 sc_threshold=0 关闭场景切换时插入关键帧。补上这两行,才有上面那份 6.000000 + 6.000000。
使用 builder 时,这个坑需要自己补上;下一节的 recipe 把它变成校验规则------segment_duration 必须是 GOP 时长的整数倍,不满足时会在 run() 之前直接报错,而不是产出一份时长漂移的播放列表让你上线后再发现。
还有一件事可以在 build 之前完成:FFmpeg 构建千差万别(发行版、vcpkg、自编译各有裁剪),与其运行到一半才失败,不如启动时先探测:
rust
use ez_ffmpeg::capabilities;
fn main() {
// FFmpeg builds differ in what got compiled in. The probe answers for
// *this* linked build, before any job is wired up.
if !capabilities::is_muxer_available("hls") {
eprintln!("linked FFmpeg build has no hls muxer");
std::process::exit(1);
}
// Segments go through the file protocol; probe it the same way. For a
// stream published elsewhere you would probe "http" or "srt" here.
if !capabilities::is_output_protocol_available("file") {
eprintln!("linked FFmpeg build cannot write files");
std::process::exit(1);
}
println!("hls muxer + file output protocol: available");
}
is_muxer_available 只回答"这个 muxer 是否编译进来",不保证运行期需要的网络、TLS 后端也都在。有个容易踩的命名空间坑:muxer 名和 protocol 名是两套体系------名叫 srt 的 muxer 是 SubRip 字幕格式,跟 SRT 流协议毫无关系,查询协议一律使用 is_output_protocol_available。编码器缺失走的是另一条路:build() 直接报 encoder 'libx264' is not available in the linked FFmpeg build,明确指出具体编码器(0.14 起),不再是一句泛泛的 not found。
ABR 阶梯:一次解码,N 路转出
对弱网用户来说,单码率就意味着转圈。ABR 要同一内容出多档"分辨率+码率",播放器按带宽自己切换。如果手写,这一套包括:split 滤镜扇出、每路 scale、每路固定 GOP 对齐关键帧、每路一个 hls muxer、最后手动拼出 master.m3u8。0.14 里这套编排是一个 recipe:
rust
use ez_ffmpeg::recipes::{HlsLadder, HlsSegmentType};
fn main() -> Result<(), Box<dyn std::error::Error>> {
HlsLadder::new("input.mp4", "hls_out")
.rendition(854, 480, "1400k")
.rendition(640, 360, "800k")
.segment_duration(4.0)
.segment_type(HlsSegmentType::Fmp4)
.run()?;
println!("wrote hls_out/master.m3u8 and per-rendition playlists");
Ok(())
}
text
┌─▶ scale 854x480 ─▶ x264(GOP 对齐) ─▶ hls muxer ─▶ 480p/...
input.mp4 ─▶ 解码 ─▶ split
└─▶ scale 640x360 ─▶ x264(GOP 对齐) ─▶ hls muxer ─▶ 360p/...
全部转码成功后 ─▶ 写 master.m3u8(BANDWIDTH = 视频+音频码率 × 1.1)
它替你完成的工作包括:生成 [0:v]split=2[s0][s1];[s0]scale=854:480,... 滤镜图;每路写死 g/keyint_min/sc_threshold=0 外加 x264 closed GOP------各路关键帧 PTS 对齐,分片边界一致,播放器才能跨码率无断点切换;master 里的 BANDWIDTH 按"视频+音频码率,再加 10% 封装开销"折算------注意这是按标称码率的工程估算,RFC 语义上该字段应为峰值段码率------它是播放器选档的依据,报小了会让播放器高估自己的带宽余量。把 HlsSegmentType::Fmp4 换成 MpegTs(或者干脆删掉那行,TS 是默认值),同一份代码就是 TS 阶梯------开头那个决策,实现层面就这一行;master 的 EXT-X-VERSION 也随之在 7 和 3 之间切换。
口径要说清楚:这里做的是结构意义上的 fMP4 HLS ------init segment、EXT-X-MAP、.m4s 分片,可以端到端用 ffprobe 验证------并不表示"CMAF 合规"或"过了 Apple 校验",模块文档原话就是这么写的。
边界按模块文档原样列出,一条也不藏着:输入必须恒定帧率(CFR),能从文件探测就探测,探测不到(比如 callback 输入)用 .fps(30, 1) 显式给;单视频流,至多一路音频;VOD 场景;加密、audio group、直播型 playlist 均不支持;master 里也不写 CODECS 属性(严格的校验器会指出这点)。还有个 Apple 特有的坑:Apple 要求 HEVC 用 hvc1 sample-entry tag,而 FFmpeg 的 MP4/fMP4 muxer 默认写的是 hev1------这是 muxer 的 codec tag 选择,与用哪个编码器无关------这个 recipe 又没有按流覆写 codec tag 的口子。面向 Apple 生态,优先用 H.264。
两个打磨细节(README 里没写的)
目录只在 build 成功后创建。 0.14 之前 ladder 先建目录树再 build,配置被拒绝------典型场景就是上面那个"链接的 FFmpeg 没编 libx264"------会留一地空目录。现在的流程:每路 Output 先把配置装好(不碰文件系统),build() 解析编码器,成功后才 mkdir。反正文件 I/O 推迟到 start() 才发生,目录早建纯属提前占坑。验证一下,故意要一个不存在的编码器:
rust
use ez_ffmpeg::recipes::{HlsLadder, HlsSegmentType};
fn main() {
// Deliberately request an encoder this FFmpeg build does not have.
let result = HlsLadder::new("input.mp4", "hls_reject")
.rendition(640, 360, "800k")
.segment_type(HlsSegmentType::Fmp4)
.video_codec("libx264_that_is_not_here")
.run();
println!("run() -> {:?}", result.err().map(|e| e.to_string()));
println!(
"hls_reject/ exists after the failed build: {}",
std::path::Path::new("hls_reject").exists()
);
}
run() -> Some("Open output error: encoder 'libx264_that_is_not_here' is not
available in the linked FFmpeg build --- ...")
hls_reject/ exists after the failed build: false
master.m3u8 同理:先把文本算好,转码全部成功后才写盘,run 失败时不会留下半成品。顺序上有一点要注意(按文档与代码顺序,本文未跑 callback 用例):callback 型一次性输入在 build 阶段就会被消费,发生在建目录之前。
Windows 路径归一化。 交给 FFmpeg 的路径其实被解析两次:一次是操作系统文件 API(Windows 上 / 和 \ 都认),一次是 hls muxer 自己的字符串切分------后者只认 /。hlsenc.c 里推导 fMP4 init segment 输出目录时使用的是 strrchr(m3u8_name, '/'),没有 DOS 路径处理;讽刺的是同一个文件里 master url 那处反而补了 \ 的 fallback,唯独 init segment 这条路没有。后果:在 Windows 上把 \ 分隔的路径传进来,init.mp4 会被静默写到 rendition 目录之外,播放器拿到的 EXT-X-MAP 就是个 404。0.14 的修法:所有进 FFmpeg 选项字符串的路径统一转成 / 分隔------Windows 文件 API 本来就认 /,无损;唯一例外是 \\?\ verbatim 路径,它对前缀敏感,改分隔符会改变指向,只能原样放行(Windows 上做 fMP4 输出,建议用普通路径)。这条契约固定在 tests.rs 里:断言的期望值直接写死 "out/720p/index.m3u8" 字面量,任何平台都是正斜杠。
跑给你看
阶梯跑完的产物树(12 秒输入,4 秒分片,每路 3 片):
hls_out/
├── master.m3u8
├── 480p/
│ ├── index.m3u8
│ ├── init.mp4 (1.4 KB)
│ └── seg_00000..2.m4s (3 片)
└── 360p/
├── index.m3u8
├── init.mp4
└── seg_00000..2.m4s
每个 rendition 都有一个 init.mp4------每路是独立的 hls muxer 实例,这是这种布局的正常结果,各自的媒体列表只引用自己的 init。master.m3u8 逐字如下:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1020800,RESOLUTION=640x360
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1680800,RESOLUTION=854x480
480p/index.m3u8
ffprobe 验收。单拿一个 .m4s 是读不出来的------头信息全在 init segment 里:
$ ffprobe hls_out/480p/seg_00000.m4s
... trun track id unknown, no tfhd was found
... error reading header
把 init 拼到前面才是完整流,这也是"单 init segment"在运维上的真实含义(TS 没有这一步):
$ ffprobe -show_entries format=duration \
"concat:hls_out/480p/init.mp4|hls_out/480p/seg_00000.m4s"
duration=4.023023
直接 ffprobe master.m3u8 检查,两档 rendition 都可读:h264 854x480 + aac、h264 640x360 + aac。两路播放列表的 EXTINF 序列逐字节相同(diff 为空)------固定 GOP 对齐的效果。
什么时候别用它
- 规模化分发、直播平台:用专业打包器(如 Shaka Packager)或 CDN 侧 JIT 打包。加密、DRM、audio group、字幕轨、LL-HLS,这个 recipe 都没有,它的定位是 VOD ABR 的最小可用集。
- 一次性转档 :
ffmpeg -i in.mp4 -f hls ...一行 CLI 更短,为单次任务写 Rust 程序是绕路。 - 真正轮到进程内的场景:打包是程序逻辑的一部分------用户上传后按需出流、程序化生成的内容直接打包、打包完在同一进程里接着做上传/记账/通知,不想管子进程生命周期和 stderr 解析的时候。
总结
现在你手里有:单码率 fMP4 的最短路径、TS/fMP4 一行切换的 ABR 阶梯、build 前探测 muxer 的写法,以及两个只有失败时才看得见的设计决策。这套 recipe 的边界(VOD、CFR、无加密)也都摆在了明处,够不够用,对着自己的场景一验便知。
完整示例在仓库的 examples/hls_conversion 和 examples/hls_abr_ladder,项目地址:github.com/YeautyYE/ez-ffmpeg。