自建 HLS 第一问:fMP4 还是 TS?用 Rust 在进程内把两种都跑出来

自建视频分发,从决定不用托管方案那一刻起,第一个要拍板的问题不是 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.m3u8init.mp4seg_00000.m4sseg_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_conversionexamples/hls_abr_ladder,项目地址:github.com/YeautyYE/ez-ffmpeg

相关推荐
Zane19941 小时前
copy 和 deepcopy 到底在拷贝什么?一文讲清赋值、浅拷贝、深拷贝的引用关系
后端·python
zhiSiBuYu05171 小时前
Flask 路由新手入门与实战指南
后端·python·flask
元界metalite1 小时前
禁止 Feign!我们为什么自研 InternalServiceClient
后端
用户125758524361 小时前
进销存后台别急着上线,先重放一次退货请求
人工智能·后端·go
qq_22589174661 小时前
基于Python的城市内涝积涝监测数据可视化分析系统
后端·python·信息可视化·数据分析·django
颜x小1 小时前
[C#]泛型类与泛型方法
开发语言·c++·c#
benben0441 小时前
大模型之基于PEFT的SFT微调实战篇
开发语言·python
苏三说技术2 小时前
为什么越来越多人用Apache Tika?
后端
Zane19942 小时前
Lock 接口与 AQS 核心原理:手写理解一把可重入锁是怎么运作的
java·后端