前阵子做一个跨平台直播推流模块视频采集、编码、推流一路做下来最折腾我的反而不是编码器参数而是 FLV 封装这一层。刚开始图省事直接把 FFmpeg 的 AVFormatContext 裸着铺进工程里结果被时间戳、metadata、关键帧索引这些细节反复教做人。后来不得不专门抽出时间把这段逻辑整理成了一个独立的 FLV 封装组件这篇文章就是我对整个过程的复盘从 FFmpeg 的 muxing 原理到工程封装实践希望对正在做推流、录制、直播工具链的朋友有参考价值。FLV 这个东西看起来简单——就是一个容器格式把 H.264/AAC 按 tag 打包往里塞——但真要在不同场景本地录制、RTMP 推流、HTTP-FLV 分发里稳定复用同一份封装代码需要处理的问题比想象中多。下面我从初始化、写入主链路、实时场景调优、踩坑记录和组件化设计几个维度展开。1. 为什么写FLV这个环节值得单独做一层封装1.1 FLV在流媒体链路中的真实地位很多人觉得 FLV 是老格式但在实际的流媒体分发里它依然扛着大量流量。RTMP 推流协议内部的媒体数据封装就是 FLV 的 tag 结构而各大播放器对 HTTP-FLV 的支持也特别成熟延迟能做到很低。相比 HLS 的分片机制FLV 天然就是一条管子——推流端持续灌数据播放端持续读数据中间几乎不需要索引和切片。正因为 FLV 在推流和直播分发里的地位这么稳固它的 muxing封装逻辑就必须做得足够健壮。你不仅要把视频帧和音频帧正确打成 tag还要处理可能出现的 B 帧时间戳重排、音频序列头缺失、关键帧位置记录等问题。这些问题如果散落在业务代码里每个调用点都可能踩一遍坑所以我选择把它们收敛到一个独立封装组件里对外只暴露简单的打开、写入、关闭接口。1.2 封装层到底在封装什么从表面看FFmpeg 已经提供了 avformat_write_header、av_interleaved_write_frame、avformat_write_trailer 这几个 API似乎直接调用就行。但能跑和能稳定用在生产环境是两码事。封装层要解决的核心问题有三个管理输出上下文的生命周期正确处理打开失败、写入中途断流、主动关闭等状态避免资源泄漏和无效调用。屏蔽不同输出形态的差异同一个 FLV muxer既要把数据写进本地文件也可能要把数据写到 RTMP 地址上还可能要封装成 HTTP-FLV 的 body。URL 不同但 muxer 逻辑可以复用只需要在初始化时把格式和协议配置好。提供对 metadata、关键帧索引、时间基的统一定位这些参数一旦分散到不同业务模块里很容易出现这边设置、那边不知道的乌龙。这就像把零散的零件装进一个工具箱调用方不需要关心内部怎么拧螺丝只要把 AVPacket 递进来我保证最终输出的是合法可播放的 FLV 流。1.3 谁适合看这篇文章如果你正在做直播推流端的开发或者想给录制模块加一个 FLV 输出能力又或者只是想在 FFmpeg 的 muxing API 基础上做一个能复用的组件这篇文章都值得看。需要的前置知识很基础知道 AVPacket 是什么、了解 H.264/AAC 基本概念就行。我对每个关键代码片段都会解释为什么这么写而不是单纯给你一段黑盒代码。2. 初始化阶段的几个关键决策2.1 选对输出格式flv muxer 和 live_flv muxer 的差别初始化时第一步是拿到输出格式实例。大多数人会直接写av_guess_format(flv, NULL, NULL)或者让avformat_alloc_output_context2自动匹配。这里有一个非常容易忽略的点FFmpeg 里其实有两个和 FLV 相关的 muxer一个是flv一个是live_flv。我做了个对比表格方便看清楚差异对比项flvlive_flv主要用途本地文件、点播文件、常规推流HTTP-FLV 直播流写入文件头/尾正常写包含 duration 等信息文件头可覆盖不写固定 durationmetadata 行为一次性写入完整 onMetaData支持动态覆盖 metadata兼容性通用播放器都能解析面向直播场景很多播放器也兼容如果你是向 RTMP 服务器推流用flvmuxer 就能工作因为 FFmpeg 协议层会负责 RTMP 的 chunk 封装。但如果你做的是 HTTP-FLV 直播分发建议用live_flv它不会在文件头上写死某些影响持续播放的参数。我在做推流模块时对外的接口里加了一个format参数初始化时根据调用方传入的目标save 还是 live来选择 muxer。这个设计不复杂但避免了后期为两种场景各写一套推流代码。2.2 输出流的添加顺序与 time_base 选择创建 AVStream 的写法很常规AVStream *st avformat_new_stream(ofmt_ctx, NULL); st-id ofmt_ctx-nb_streams - 1;但有几个细节需要提前想好。第一视频流和音频流的添加顺序会影响 FLV 文件里 tag 的排列顺序。实际经验是先视频后音频更符合播放器预期因为播放器往往通过第一个 video tag 的 sequence header 来初始化 H.264 解码器。如果你先发音频 tag某些播放器可能出现画面已经来了但解码器还没初始化的怪问题。第二time_base的选择要统一。FLV 内部的时间戳单位是毫秒而编码器输出的 AVPacket 时间戳可能基于{1, 90000}或者{1, 1000000}。我习惯把AVStream-time_base设置成{1, 1000}这样 FFmpeg 在写包时会自动把时间戳换算成毫秒不需要我手动 sc 每个包。st-time_base (AVRational){1, 1000};如果是直播推流建议设置av_dict_set(opts, use_wallclock_as_timestamps, 1, 0)这会让 muxer 忽略编码器传进来的 pts 计算绝对时间对弱网环境下的恢复有帮助。2.3 编码器参数会在初始化阶段提前暴露问题封装层不能完全和编码器脱钩因为很多 FLV 相关特性取决于编码器怎么配置。比如 H.264 的sps、ppsFFmpeg 的 FLV muxer 会在写头之后、第一个关键帧之前自动生成 AVC sequence header前提是你的AVCodecParameters里正确填充了 extradata。如果你用avcodec_parameters_from_context(st-codecpar, enc_ctx)来同步参数要确保编码器已经完成了初始化并且确实输出了 extradata。我在对接某家硬件编码器时发现它的 extradata 不是立刻生成的需要先编码几帧才会出现。这种情况下如果你过早调用写头FLV 里就会缺少 sequence header播放器拿到流之后连 SPS/PPS 都没有直接黑屏。我的建议是如果使用第三方编码器封装层初始化之后不要立刻 trigger write_header可以等第一个关键帧准备好后再统一开始。此时 extradata 已经带上metadata 信息也更完整。这个延后启动的思路对很多实时采集场景都适用。3. Muxing主链路从AVPacket到FLV字节流3.1 avformat_write_headerFLV 元数据的决定时刻FLV 文件头之后紧跟着的是一个 script data tag也就是常说的 onMetaData。这个 tag 里包含时长、码率、分辨率、关键帧索引等信息播放器首屏加载速度快不快很大程度上取决于 meta 里的关键字段是否完整。avformat_write_header会在内部处理这一切但它能写多少信息取决于你在调用前给了它多少材料。我在封装组件里专门留了一个注入 metadata 的接口允许调用方设置 title、rotate、creator 等自定义字段av_dict_set(ofmt_ctx-metadata, title, live_stream, 0); av_dict_set(ofmt_ctx-metadata, rotate, 90, 0);要注意rotate这种字段对手机竖屏推流很重要。如果编码器给出的是横屏分辨率和角度信息FLV 里如果不写rotate播放端就得自己猜旋转方向。做直播手机推流时不写 rotate 的后果就是手机竖着拍播放端看到的却是横着的画面。还有一个容易被坑的点是creation_time。FFmpeg 默认会写入 UTC 时间但如果你的流是直播流这个时间会变成流的创建时间而不是当前时间。如果你希望 metadata 更可控可以在写头时通过AVDictionary把flvflags设置为no_duration_filesize或no_metadata告诉 muxer 不要自动生成某些字段然后自己手动构造 metadata script data。3.2 av_interleaved_write_frame 和 av_write_frame 怎么选写数据包时有两个 API 经常被搞混一个是av_write_frame一个是av_interleaved_write_frame。区别在于后者内部会自动做 packet 交错排序它维护一个队列按 dts 排序后再输出保证音频、视频包交错合理。FLV 和 MP4 不同它本身没有类似 moov 的索引结构时间和字节顺序完全靠写入顺序决定。如果用av_write_frame直接写一旦编码器输出顺序里出现 B 帧重排或者音频包和视频包时间戳交替异常生成的 FLV 在播放时就会出现音画不同步。实测下来除非你非常确定自己的包已经按 dts 严格排序了否则都用av_interleaved_write_frame更稳妥。另外av_interleaved_write_frame是可以传入 NULL 来强制冲刷内部队列的。这在结束录制前特别重要av_interleaved_write_frame(ofmt_ctx, NULL);如果不做这一步编码器最后几帧可能还滞留在交错队列里FLV 文件会少一截尾部数据。很多录制出来的视频播放到最后会突然断掉的问题往往就是忽略了这次 flush。3.3 收尾write_trailer 与异常退出处理正常结束流程应该是av_write_frame(fmt_ctx, NULL); // 冲刷交错队列 avformat_write_trailer(fmt_ctx); avio_close(fmt_ctx-pb); avformat_free_context(fmt_ctx);但如果程序在直播中途退出比如网络断开、用户主动停止写 trailer 的行为就要小心。对 RTMP 推流来说write_trailer 会尝试发送一个 sequence end 标签告诉服务器流结束了。如果链路本来就已经断掉这一调用反而会阻塞很久。我的封装组件里给关闭接口加了一个graceful参数正常结束时走完整 writer trailer 流程异常中断时直接跳过 trailer 只释放资源。另外要注意avio_close可能返回写入错误码。对于本地文件关闭时错误往往代表磁盘空间不足或者 IO 异常不能只打印一行日志就完事。我把这个错误码纳入统一错误回调让上层可以感知到录制任务其实没成功结束。3.4 一个最小可运行的封装核心代码下面这段代码不是完整的组件但它浓缩了 FLV muxing 的核心链路方便理解整体调用关系AVFormatContext *create_flv_muxer(const char *url, int is_live) { AVFormatContext *fmt_ctx NULL; const char *fmt_name is_live ? live_flv : flv; int ret avformat_alloc_output_context2(fmt_ctx, NULL, fmt_name, url); if (ret 0) return NULL; if (!(fmt_ctx-oformat-flags AVFMT_NOFILE)) { ret avio_open(fmt_ctx-pb, url, AVIO_FLAG_WRITE); if (ret 0) { avformat_free_context(fmt_ctx); return NULL; } } return fmt_ctx; } int write_flv_packet(AVFormatContext *fmt_ctx, AVStream *st, AVPacket *pkt) { // pkt-pts/pkt-dts 需要按 st-time_base 传入内部统一转成毫秒 return av_interleaved_write_frame(fmt_ctx, pkt); } int finish_flv(AVFormatContext *fmt_ctx, int graceful) { av_interleaved_write_frame(fmt_ctx, NULL); if (graceful) { avformat_write_trailer(fmt_ctx); } if (fmt_ctx-pb) avio_close(fmt_ctx-pb); avformat_free_context(fmt_ctx); return 0; }这只是骨架真正的工程代码还要加锁、错误码映射、状态流转这些我在第 6 节展开。4. 实时场景下的FLV封装调优4.1 直播时延的隐患藏在内部交错队列里直播场景最怕延迟越堆越高。很多人只关注编码器到服务器之间的网络延迟却忽略了 muxer 内部也可能缓存大量数据。av_interleaved_write_frame 的交错队列如果音频包迟迟不来视频包就会在队列里等它反过来如果视频包间隔过长音频包也会被积压。默认的max_interleave_delta是 0意思是不限制队列深度这在某些异常情况下可能造成内存暴涨。我在直播推流组件里显式设置了fmt_ctx-max_interleave_delta 200000; // 单位微秒即最多缓冲 200ms这样即使某一侧编码器突然抽风muxer 也会尽快把能写的包先写出去而不是死等另一个流。4.2 GOP 大小和关键帧索引的博弈FLV 的 seek拖动进度条依赖关键帧索引。播放器想跳转到某个时间点必须找到离那个时间点最近的关键帧位置。本地录制时你可以通过flvflagsadd_keyframe_index让 muxer 额外生成一个关键帧索引区块这样播放器 seek 会非常顺滑。但直播场景一般不推荐这样做。原因很简单add_keyframe_index 会记录所有关键帧的文件偏移这要求 muxer 能够回看或者缓存足够多的 offset 信息对长连接直播流来说既浪费内存也没有实际意义——直播本来就不需要 seek。GOP 大小也会直接影响 FLV 的关键帧密度。通常 1 到 2 秒一个关键帧比较合适过大的 GOP 会让播放器在首屏等待时花更久时间等关键帧过一个小的 GOP 又会降低压缩效率。如果你做的是直播推流建议编码器参数里把 gop_size 圈定在帧率的 1 到 2 倍之间。4.3 音频序列头检测与常见免配置项FLV 里 AAC 音频流如果缺少 AudioSpecificConfig也就是 AAC sequence header播放器几乎无法解码。FFmpeg 的 FLV muxer 默认认为 AAC 数据包的第一个包必须是 sequence header但有的编码器并不会自行输出这个 header。好在 muxer 还有一个flvflagsaac_seq_header_detect选项开启后它会从裸的 AAC 数据中尝试解析并生成序列头省掉很多兼容性问题。我测试下来对某些硬件编码器输出的裸 AAC 数据特别有效。av_dict_set(opts, flvflags, aac_seq_header_detect, 0);需要注意开启这个选项会增加一定解析开销但对兼容性的收益远大于这点开销。做多端适配时我强烈建议默认开启能少挨不少播放器兼容性的骂。5. 实测踩坑与排查清单5.1 本地录制文件无法拖动进度条某次录制测试播放器能播放但拖动进度条直接卡死。排查下来是 FLV metadata 里缺少关键帧索引FFmpeg demuxer 只能顺序读无法随机 seek。解决方案就是在打开 FLV 输出时设置flvflagsadd_keyframe_index。这个 flag 会额外在文件尾生成一个索引块代价是文件略大、写入时间稍长。如果不是对文件体积极度敏感的本地录制场景建议直接开启。开了之后我用三指拖动测试播放器和剪辑工具都能正常定位。5.2 播放器首帧黑屏或花屏首帧黑屏的问题常见于三种情况现象可能原因排查/处理一直黑屏无画面缺少 AVC sequence header等待编码器生成 extradata 后再 write_header花屏后恢复SPS/PPS 不匹配检查 extradata 是在哪里复制的首秒黑屏后正常GOP 过大调小 gop_size画面颠倒或旋转缺少 rotate metadata在 metadata 中明确写入旋转角度其中 sequence header 缺失这个问题最隐蔽。我一直强调延后启动策略就是因为在某些编码器上 extradata 不是立即可用的。做封装组件时我把编码器就绪作为 start 前置条件从根源上避免这种问题。5.3 时间戳漂移导致音画不同步FLV 的时间戳单位毫秒如果音频和视频的时间基不一致在换算时容易出现轻微漂移。举例视频按 90000 换算成毫秒33 毫秒一帧音频按采样率换算20 毫秒一帧两者交叉写入久了某一方就可能前后插错位置。av_interleaved_write_frame 能保证写出的包按 dts 排序但前提是你传进来的包 dts 是正确的。我见过最离谱的问题是从 SDK 里拿到的音频包 pts 是空的muxer 直接当成 0 处理结果音频几乎全部挤在开头音画不同步到没法听。排查思路是把每 100 帧的 pts 差值打印出来对比实际采样间隔是否一致不一致的话回到编码器端修正时间戳。5.4 推流中途网络断开导致的资源不释放RTMP 推流时如果服务器主动断开av_interleaved_write_frame返回的错误往往是AVERROR(EIO)或者AVERROR(EPIPE)。如果业务侧不处理muxer 内部可能还有积压的 packet关闭时又重复调用写接口直接出现崩溃现场。我的做法是在写入接口返回值小于 0 时立刻进入 fatal 状态记录错误码并主动关闭所有队列。后续上层的编码线程收到失败回调后停止编码避免无限往里塞数据。这个状态流转逻辑让整个模块在断网场景下恢复得很快不会出现假死进程。6. 从封装到组件工程化设计的一些思考6.1 线程模型与队列缓冲推流模块里通常是编码线程和封装线程分离编码器在专属线程上工作生成的 AVPacket 进入一个有界队列封装线程从队列里取出并调用 muxer。封装组件本身不应该持有编码队列它只负责取包写入。线程安全的关键有两点第一AVFormatContext 不能被两个线程同时访问所有 muxer 调用必须集中在一个线程第二外部传进来的 AVPacket 在进入封装接口后内部要负责释放调用方不要再碰。我在组件内部定义了一个简单的环形缓冲队列容量按帧数配置。直播场景下 30 到 60 帧的缓冲就足够平滑抖动过大只会增加延迟。6.2 状态机与错误码一个可靠的封装组件需要明确的内部状态我定义了四个状态未初始化、已初始化、写入中、已关闭。每个方法都有状态校验防止在错误时机调用。typedef enum { MUXER_IDLE, MUXER_READY, MUXER_RUNNING, MUXER_CLOSED } muxer_state;每次调用返回统一错误码比如ERR_NOT_READY、ERR_WRITE_FAILED、ERR_INVALID_PARAM。这比直接返回 FFmpeg 的负值错误码更友好上层代码不用查 AVERROR 宏表。6.3 复用与扩展本地录制、直播推流、多路分发把 FLV muxer 封装成独立组件后最大的收益就是可以复用。本地录制可以直接复用同一套代码只是输出 URL 从rtmp://换成普通文件路径实时直播分发需要 live_flv 参数那就初始化时传is_livetrue多路分发可以同时创建多个 muxer 实例指向不同目标每个实例有独立的锁和状态。实际项目中我还用这个组件做过边录边推一份数据同时写入本地 FLV 文件和 RTMP 流两个 muxer 实例之间共享同一个数据源但各自有独立缓冲。这个用法验证了封装设计的一个原则——把包处理和目标输出解耦数据进组件时只认 AVPacket输出目标只认 URL 和格式参数。只要守住这个边界组件怎么扩展都不会变成一坨乱麻。
阅读完成 · 觉得有帮助?