ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FFmpeg转封装核心:avformat_write_header原理与实战排查指南

2026/9/30 4:59:20 拓冰建站 浏览量
FFmpeg转封装核心:avformat_write_header原理与实战排查指南 我最早碰avformat_write_header这个函数是在给公司的转封装服务加 MP4 录制功能的时候。代码写得很顺avformat_alloc_output_context2、avio_open、av_interleaved_write_frame一路下来最后文件也生成了但拿播放器一打开就报文件损坏。排查了很久最后发现是漏了avformat_write_header。那之后我对这个函数的定位彻底改变了——它不是一个可选的初始化步骤而是输出管道的生死线。这篇就把我从踩坑到理解、再到能熟练排查问题过程中积累的东西完整写下来围绕avformat_write_header的函数原理、调用时机、参数细节、错误处理以及它在文件录制和网络推流中的真实行为展开给正在从命令行转向 FFmpeg API 开发的读者一份可以直接参考的实战笔记。1. 为什么转封装管道里这行代码能决定文件生或死函数定位与底层角色1.1 一次文件打不开的翻车现场当时我接手的模块输入是 RTSP 摄像头的 H.264 裸流输出要落成 MP4 文件。由于源数据已经是 H.264同事最初觉得转封装不需要重新编码只要把包写进去就行于是写代码时把avformat_write_header整行注释掉了理由听上去还挺合理我们又不改编码写 header 有什么用结果产出的文件大小始终是 0 字节偶尔有几个字节播放器全部打不开。后来翻 FFmpeg 源码和文档才明白MP4 这类容器不是简单的字节流拼接它的文件结构要求在开头写入ftypbox 和moovbox播放器要靠这些 box 才能知道文件里有几条轨道、视频编码是什么、时间基准是多少。没有这一层目录索引后面写再多的包数据也没有意义。你可以在avio_open之后随便写几千字节的垃圾数据文件可能也能打开但播放器读到的轨迹完全是错的。这个教训让我后来养成一个习惯凡是自己写 FFmpeg API 程序第一步先把avformat_write_header和av_write_trailer这两个函数的调用位置确定下来再动手写中间的打包循环。很多文件损坏花屏播放器无法定位时长的问题根因都不是编码器参数而是这一头一尾没处理好。1.2 write_header、open_input、io_open的分工边界要理解avformat_write_header必须先把它和周边几个 API 的分工划清楚。很多人一开始会把打开输入文件、打开输出文件、写封装头混在一起导致上下文一混乱就报错。avformat_open_input负责读路径。它打开输入文件或流通过探测数据识别容器格式填充输入的AVFormatContext。avio_open负责底层 I/O 通路。不管是本地磁盘文件、内存缓冲区还是 RTMP 网络连接AVIOContext都是统一的数据出口。avformat_write_header负责输出路径上的封装层初始化。它拿到你已经配置好的AVFormatContext把它交给ctx-oformat对应的封装器回调让封装器在真正写媒体数据之前把容器自身的状态建立起来。这里的容器自身的状态每个格式都不一样。写 MP4回调要生成ftyp和moov写 FLV回调要生成 FLV header 和onMetaDatascript tag写 HLS回调要初始化 m3u8 索引并准备第一个分片。这也是为什么我建议新手直接去读源码里libavformat/mux.c的avformat_write_header实现它做的事情比大多数人以为的多得多int avformat_write_header(AVFormatContext *s, AVDictionary **options)函数的核心逻辑是先检查上下文状态和流参数再把options里的键值对按协议层、封装层分别分发最后调用s-oformat-write_header。如果封装器的write_header返回错误整个输出流程会直接中止后面你再写包封装器也不会接受。所以你可以把avformat_write_header理解成一个构造函数它负责把输出管道初始化到可以接收媒体包的状态。没调用它输出管道就是一根没有插头的电线电压再高也点不亮灯泡。2. 铺垫工作做不对header永远写不出去调用前的AVFormatContext与AVStream配置2.1 从函数签名看使用约束avformat_write_header的声明非常简洁int avformat_write_header(AVFormatContext *s, AVDictionary **options);只有两个参数但很多人在第二个参数上栽过跟头。options的类型是AVDictionary **不是AVDictionary *它设计成这个类型是因为函数内部会把当前格式不认识的选项留在字典里返回给你方便你调用结束后检查有没有拼错的参数名。这一点后面还会细说先看第一个参数调用前它必须满足三个前提。第一s-oformat必须非空。通常通过avformat_alloc_output_context2创建上下文时就已经绑定好了。如果你用avformat_alloc_context手动分配再忘记赋值ctx-oformatavformat_write_header会直接返回AVERROR(EINVAL)。第二s-pb必须已经通过avio_open打开。avio_open的第二个参数是输出 URL第三个参数要传AVIO_FLAG_WRITE。如果打开失败函数内部会在写头时报 I/O 错误日志里往往能看到Unable to open URL之类的信息。第三ctx-nb_streams必须大于 0。封装器需要知道至少一条流的信息才能构造轨道描述。即使是纯音频或者纯视频也必须通过avformat_new_stream创建出流对象。2.2 AVStream必须携带哪些信息才能过关比上下文更关键的是流信息。avformat_write_header内部会读取每个AVStream的codecpar、time_base、avg_frame_rate、sample_aspect_ratio等字段用来生成轨道信息。其中最容易踩坑的是time_base。从命令行切到 API 的开发者经常会发现一个问题用ffmpeg -i input.mp4 -c copy output.mkv命令时一切正常换成自己写代码后输出的文件时长不对或者在写包时狂刷Application provided invalid, non monotonically increasing dts to muxer。这个问题的直接原因就是输出流的time_base没有设置成和 packet 时间戳一致的单位。举个例子你的编码器输出帧率是 25fpspacket 里的 pts 按 1000 为单位递增也就是每帧 40。但你没有设置out_stream-time_base它默认是{0, 0}或某个残留值。封装器计算 DTS 时就会得到一团乱码写进容器的时间轴自然错了。我一般会在创建输出流后立刻做三件事AVStream *out_stream avformat_new_stream(ctx, NULL); avcodec_parameters_copy(out_stream-codecpar, codec_ctx-codecpar); out_stream-time_base (AVRational){1, 1000};注意第三行这是我个人的默认习惯。实际用{1, 1000}还是{1, 90000}取决于封装格式和业务需求。MP4 用{1, 1000}通常没问题MPEG-TS 和部分直播场景用{1, 90000}更稳妥。关键是不要留成默认值。除了time_basecodecpar里的extradata也必须保证正确。H.264 的 SPS/PPS、H.265 的 VPS/SPS/PPS在封装成 MP4 时要写进avcC/hvcCbox在 FLV 里要写进AVCDecoderConfigurationRecord。avformat_write_header不会替你去解码器里取这些信息它只负责把你准备好的 extradata 转换格式。如果你的输入是裸流必须先通过avcodec_parameters_from_context把编码器上下文的参数抄到codecpar上再拷给out_stream-codecpar。漏掉这一步封装器虽然也能写头但生成的流在某些播放器里会黑屏或绿屏。2.3 附加信息metadata、chapter 和 attached_pic 的写入时机avformat_write_header不只是写轨道它还会把ctx-metadata、ctx-chapters以及附带封面图一并写入封装层。这个特性容易被忽略但它对文件的可读性影响非常大。命令行里的-metadata titlexxx在 API 里的实现方式就是在调用avformat_write_header之前通过av_dict_set把键值对挂到对应对象的 metadata 字典上。MP4 封装器会在udta/metabox 里写入标题、语言、创建时间FLV 封装器会在onMetaDatascript tag 里带上这些信息。如果你不调av_dict_setaffprobe 查出来的文件标题栏就是空的这不是封装器的问题而是源头上没设。举个例子av_dict_set(ctx-metadata, title, My Video, 0); av_dict_set(stream-metadata, language, eng, 0);这两个调用要在avformat_write_header之前完成因为封装器只在写头阶段读取 metadata。如果你在写完头之后才设置字典这些信息不会被写入文件。章节信息和封面图也是同理。ctx-chapters需要在你调用avformat_write_header前构建好封面图则是在某个AVStream上通过attached_pic字段挂载里面存放图片编码后的AVPacket。我做过几次带封面的 MP4最大的感受是封面图的codecpar必须是 JPEG 或 PNG封装器对附加图片的编码格式有严格要求否则写头阶段会直接报错。3. 写头不是随手一写调用位置、返回值与错误处理的完整细节3.1 正确的调用位置从分配上下文到写trailer的完整流程先给出一段可以抄作业的核心流程。这里省略了编码器初始化和 packet 生成逻辑只展示与avformat_write_header强相关的骨架AVFormatContext *ctx NULL; avformat_alloc_output_context2(ctx, NULL, NULL, output.mp4); if (!ctx) { // 处理失败 } if (avio_open(ctx-pb, output.mp4, AVIO_FLAG_WRITE) 0) { // 处理失败 } AVStream *out_stream avformat_new_stream(ctx, NULL); avcodec_parameters_copy(out_stream-codecpar, codec_ctx-codecpar); out_stream-time_base (AVRational){1, 1000}; AVDictionary *opts NULL; av_dict_set(opts, movflags, faststart, 0); int ret avformat_write_header(ctx, opts); if (ret 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE] {0}; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, write header failed: %s\n, errbuf); return -1; } // 循环写入 packet while (/* 有 packet */) { av_interleaved_write_frame(ctx, pkt); } av_write_trailer(ctx); avio_closep(ctx-pb); avformat_free_context(ctx);这段代码里的位置顺序非常重要。avformat_new_stream必须在avformat_write_header之前因为封装器需要遍历所有流。avio_open也必须在 write header 之前因为 FLV 和 MP4 的头部数据要写到ctx-pb指向的 IO 上。如果你把avio_open放在avformat_write_header之后函数会因为没有可写的 IO 返回错误。还有一个经常被忽略的点av_write_trailer也必须调用。MP4 封装器在 write header 阶段会预留一部分字节等到av_write_trailer时才把真正的时长、文件大小回填到moov里。如果只调 write header 不调 trailer文件头尾信息就会对不上播放器显示异常时长甚至无法播放。3.2 返回值对照遇到AVERROR(EINVAL)、AVERROR_INVALIDDATA怎么办avformat_write_header返回 0 表示成功负数表示失败。我在几个项目里遇到过的错误返回值整理成了一张表返回值实际原因排查方向AVERROR(EINVAL)上下文为空、oformat缺失、pb未打开、编码参数不合法优先检查ctx-oformat和ctx-pb是否有效AVERROR_INVALIDDATA流参数与封装格式不匹配、没有可用流、extradata缺失检查codecpar和extradata确认流的编码id是否被容器支持AVERROR(ENOMEM)内部申请内存失败看系统内存检查是否有内存泄漏AVERROR(EIO)底层写入失败检查磁盘空间、文件权限、网络连接状态AVERROR(EPIPE)RTMP 等网络协议中断检查服务端是否断连握手是否完成遇到AVERROR(EINVAL)我的排查顺序是固定的先看ctx-oformat是否为 NULL再看ctx-pb是否已经打开最后才怀疑流参数。这三个问题里第二个最隐蔽因为avio_open失败时你可能只打了日志但没有及时返回导致后面拿着一个空的pb继续走流程。遇到AVERROR_INVALIDDATA优先检查codecpar从哪里拷来的。我见过一个很典型的错误开发者从输入文件的AVStream-codecpar拷贝参数到输出流但输入文件的编码格式在输出容器里不被支持比如把 MPEG-2 视频写进 FLV。FLV 封装器对 CodecID 有严格限制只支持 H.264、HEVC、VP6 等少数几种不支持的编码会导致 write header 阶段返回数据无效错误。3.3 options参数到底传了什么AVDictionary与封装器私有选项第二个参数options是avformat_write_header最灵活的地方。很多人只知道传 NULL实际上你可以通过它传递封装器的私有选项影响写入行为。AVDictionary本质上是一个键值对容器。调用时你可以先把想设置的选项放进去AVDictionary *opts NULL; av_dict_set(opts, hls_time, 5, 0); av_dict_set(opts, hls_list_size, 20, 0); avformat_write_header(ctx, opts);avformat_write_header内部会遍历这个字典把当前封装格式能识别的键取走并生效。处理完剩下的键会留在字典里返回给你所以调用后检查一下av_dict_count(opts)是否大于 0是判断参数名是否拼写正确的一个有效手段。这个技巧在命令行里对应的就是-hls_time 5 -hls_list_size 20只是命令行工具替你完成了字典的构造和传递。要注意options不仅会影响封装层还会影响协议层。比如对 RTMP 输出你可以通过它传rtmp_buffer、rtmp_live等协议参数。在使用网络输出时这个通道非常关键。我曾经在推流到 SRS 时遇到反复断连后来发现是 RTMP 协议层的 buffer 设置太小通过在avformat_write_header前设置rtmp_buffer解决了问题。4. 文件录制和网络推流的头信息差异从FLV/RTMP到HLS的行为观察4.1 本地mp4和网络FLV在write_header那一刻做的事完全不同同一个avformat_write_header面对不同封装格式时内部行为差异大到会让你怀疑是不是调了两个函数。我把文件录制和网络推流最典型的两个场景分别拆开看。本地 MP4 场景avformat_write_header做的事情是生成ftypbox生成moovbox把每个流的编码信息、时间基、语言元数据写进去。moovbox 里还包含每个 track 的 sample table 的起始偏移位置。这些信息在写包阶段会被不断更新最终在av_write_trailer时回填。网络 FLV/RTMP 场景avformat_write_header做的事情则完全不同它先把 9 字节的 FLV header 写进 RTMP 流紧接着写一个onMetaDatascript tag里面包含音频编码、视频编码、宽度、高度、帧率、码率等信息。这些信息是播放器解析流的第一份数据如果时序不对服务端和播放端都会出问题。HLS 场景又不一样。avformat_write_header会初始化 m3u8 播放列表决定是否生成#EXT-X-MEDIA-SEQUENCE并准备好第一个 TS 分片。HLS 的write_header行为还和hls_time、hls_list_size这些私有选项强相关所以通过 options 传入的参数会在这一步直接生效。理解了这些差异调试时才不会犯本地文件正常网络推流失败时一脸懵的错。本地文件写坏了可以用 ffprobe 看文件头网络流只能靠协议分析和服务端日志定位难度高一个量级。4.2 推流到SRS有延迟write_header是不是该背锅搜索 FFmpeg 相关问题时常能看到推流到 SRS 存在延迟的讨论。这里把话说透延迟问题和avformat_write_header的关系不大更多来自编码参数和拉流播放端的缓冲策略。avformat_write_header对延迟唯一可能产生影响的是元数据里duration和filesize字段的取值。对于直播流FLV 封装器默认会在onMetaData里写出duration0、filesize0表示这是一个不定长的实时流。如果你之前设置过flvflags或某些私有选项导致这两个字段被填成了具体数值某些播放器可能会按这个值做缓冲判断出现延迟增大。我自己的做法是在推流版项目里这样设置AVDictionary *opts NULL; av_dict_set(opts, flvflags, no_duration_filesize, 0); avformat_write_header(ctx, opts);这能让 FLV 封装器不输出 duration 和 filesize播放器就不会错误地按文件模式缓冲。但注意这只是辅助手段。真正影响延迟的核心是编码端的 GOP 大小、tunezerolatency有没有设置、B 帧是否关闭、播放端的buffer_time配置。所以如果推流延迟高不要一上来怀疑 write_header先看编码器和播放端参数。5. 实测中write_header失败的四个经典场景与完整排查思路5.1 time_base未设置导致的Header writing failed前面提到过 time_base 的重要性这里展开讲一个真实的排查链路。某个项目的输出是 MPEG-TS 文件程序从解码器拿到帧直接打包写入输出流。运行后日志里不断出现Application provided invalid, non monotonically increasing dts to muxer最开始我以为是编码器的时间戳跳变后来发现是因为out_stream-time_base没有设置而 packet 的 pts/dts 是按{1, 90000}递增的。封装器拿到了一组它无法理解的时间值自然写不出正常的头信息。排查思路是这样的先打印out_stream-time_base和第一个 packet 的 dts、pts发现数值完全对不上。再把输出流的time_base改成和 packet 一致的{1, 90000}问题立即消失。整个过程只花了 20 分钟但之前没有思路时卡了小半天。建议你在创建输出流后打印一行日志确认time_baseav_log(ctx, AV_LOG_INFO, out stream time_base: %d/%d\n, out_stream-time_base.num, out_stream-time_base.den);5.2 空流和单流文件write_header对流数量的强制要求绝大多数封装器都要求ctx-nb_streams 0。遇到只有一个音频或只有一个视频的文件只要流数量不为零就没问题。但如果你的程序做了过滤逻辑把音频流全滤掉了或者编码失败导致流没有有效数据write header 阶段就会暴露问题。我遇到过一个案例批量转码脚本里用户上传了一个空视频轨的素材程序里音频拷贝正常视频编码失败导致视频流没有有效 packet。avformat_write_header返回的错误信息是stream number is zero具体含义是视频轨虽然存在但没有可用的编码参数。解决方法是编码前先检查codecpar-codec_id是否合法编码后检查codec_ctx-frame_number是否大于 0不满足条件就不创建输出流。5.3 非基本流数据attachment和subtitle需要特别处理如果输出 MP4/MKV除了音视频还有封面图、字幕轨道等附加数据。封面图在 AVStream 上通过attached_pic表示这个结构里的data字段存有图片编码后的数据。如果你不处理它write_header 会因为封面流缺少有效编码数据而报错。字幕流也是类似的坑。ASS 字幕的codecpar-extradata里存放的是脚本头信息比如字体声明、分辨率、事件格式。如果这个字段是空的封装器虽然能写出轨道但播放器渲染字幕时会乱码或无法显示。正确做法是在 write_header 前把字幕解码器上下文里的参数完整拷到out_stream-codecpar。5.4 复用同一个AVFormatContext反复write_header的隐患不要在同一个 AVFormatContext 上调用两次avformat_write_header。这是我在做循环录制功能时被坑出来的经验。最初为了减少 CPU 开销我想复用上下文于是在每个录制周期开始前重新avio_open然后再次调用avformat_write_header。结果第二次调用时MP4 封装器直接返回AVERROR(EINVAL)。查了源码才知道MP4 的mov_write_header里有状态判断moov 一旦写过就不会允许再写。后来的做法是每个录制周期都释放旧上下文、重新分配新上下文问题才消失。如果你也有类似的循环录制需求建议直接写成每次循环都从avformat_alloc_output_context2开始而不是试图复用。虽然多了一点分配开销但远比排查状态残留问题省时间。6. 从命令行反推API行为用ffmpeg CLI和ffprobe验证头信息6.1 ffmpeg命令行内部如何调用avformat_write_header如果你用过ffmpeg -i input.mp4 -c:v libx264 -f flv rtmp://...那么实际上你已经接触过avformat_write_header了。命令行工具内部的处理流程和 API 程序基本一致为输出文件创建 AVFormatContext创建输出流拷贝或转换编码参数然后调用 write header。很多命令行参数都能在 API 里找到对应关系。我整理了一个速查表命令行参数API 对应方式-movflags faststart在 options 里设置movflagsfaststart-flvflags no_duration_filesize在 options 里设置flvflagsno_duration_filesize-hls_time 5在 options 里设置hls_time5-metadata titlexxxav_dict_set(ctx-metadata, ...)-f flvavformat_alloc_output_context2的 format 参数这个对应关系对排查问题非常有用。比如你发现命令行输出正常但 API 输出文件无法播放先对比两者的参数设置往往能快速找出差异。6.2 用ffprobe检查write_header写出的内容是否合理写完文件后用 ffprobe 检查头信息是否正确是必不可少的一步。ffprobe -v error -show_format -show_streams output.mp4重点看几个字段format_name、duration、nb_streams以及每个 stream 的codec_name、time_base、extradata。如果duration等于 0很可能是 write header 前 time_base 设置不正确如果某个 stream 的extradata为空那 H.264 的 SPS/PPS 就没有写进容器。还有一个更专业的检查方式用ffprobe -show_packets看第一个关键帧的位置。MP4 和 HLS 都会依赖头部索引来定位关键帧如果头部索引和实际数据偏移不一致播放器会出现能拖进度条但画面卡住的问题。6.3 我的调试习惯日志观察、strace和二分法如果通过 ffprobe 还是定位不了问题我的调试三板斧是打开 FFmpeg 日志、用系统调用跟踪、做二分法对比。打开日志的方式很简单av_log_set_level(AV_LOG_DEBUG); av_log_set_callback(av_log_default_callback);在调用avformat_write_header前后各打一行日志观察日志里 muxer 有没有打印具体的错误信息。FFmpeg 在 DEBUG 级别会把很多封装器内部的诊断信息打印出来包括某个字段缺失、某个参数不合法。这些信息比返回值本身有用得多。如果怀疑是底层 I/O 问题我会用系统调用跟踪工具看实际写入了多少字节。文件权限、磁盘空间、网络连接状态都可能让avio_open表面成功但实际写入失败。比如网络推流时服务端可能在握手阶段就断开了连接write header 阶段虽然返回 0但后面写包时全部失败。二分法对比就更直接先改成写本地文件如果本地文件正常问题大概率在网络协议或服务端配置如果本地文件也不正常继续把输出从 MP4 换成 AVI逐步缩小问题范围。这个方法看起来笨但面对封装器报的模糊错误时几乎是效率最高的定位手段。个人体验下来avformat_write_header这个函数最大的特点是表面简单实际复杂。参数就两个但埋着上下文、流参数、I/O 状态、格式私有选项四层依赖。一开始我把它当作随手一行 API踩了两次坑之后才真正重视起来。建议所有做 FFmpeg API 开发的人都把自己项目的封装流程画成一张时间线标明每个 API 的调用位置尤其是 write header 和 write trailer 这两端画清楚之后很多错误其实一眼就能看出来。最后再分享一个小技巧如果怀疑自己的流参数或 metadata 配置有问题可以在写完一个仅有头尾、没有媒体 packet 的空壳文件后用 ffprobe 验证它能正常解析说明配置没问题剩下的问题基本都出在打包循环里。