尧图精选

FFmpeg avformat_new_stream 剖析:AVStream 创建与封装核心逻辑详解

🕒 发布时间:2026/10/30 20:43:23 📁 来源:尧图网络
1. 一条流的诞生avformat_new_stream 到底在干什么做过 FFmpeg 二次开发的朋友大概率都在这两处碰见过avformat_new_stream写 MP4、FLV、MKV 这类文件时调用它创建音频流和视频流读文件时如果打印AVFormatContext的流列表底层同样会通过它给每个轨道分配一个AVStream。这个函数在音视频开发里的出场频率极高但很多人在最初使用阶段其实只把它当成“申请结构体的工具”没认真想过它背后替我们做了哪些初始化、哪些默认值不会自动填、哪些坑属于它的“职权范围”。先给它下一个定位avformat_new_stream的作用是在AVFormatContext中创建一个新的AVStream节点这个节点代表容器里的一个逻辑流一个视频轨道、一条音频轨或者字幕、数据流。函数签名如下AVStream *avformat_new_stream(AVFormatContext *s, const AVCodec *c);参数s是容器上下文第二个c是编解码器描述符指针通常传NULL或者在某些场景下传入已知的AVCodec来预置流的codec_id和codec_type。返回值是指向新分配AVStream的指针分配失败时返回NULL。从分配成功那一刻起这个流的生命周期就交回AVFormatContext管理之后调用avformat_free_context时它会被统一释放不需要也不需要允许手动av_free。这个函数适用的人群很明确你在用 libavformat 做封装器muxer、解封装器demuxer或者只是写一个把裸流封装成容器的工具都避不开它。理解它内部的初始化逻辑能让你在真正的使用场景里少走很多弯路——比如某些字段明明看起来“应该是自动填好的”实际却没有某些字段明明设置了却因为调用顺序问题被覆盖。下面从它的底层实现、典型调用路径、真实踩坑案例三个维度展开尽量把这一个函数讲透。2. 从创建流到写出文件头muxing 场景的标准调用链路我们在做输出封装时最典型的一整套流程是分配输出上下文 → 创建流 → 填充编解码参数 → 设置时间基 → 写入文件头。avformat_new_stream在这个链路里是承上启下的位置但它前后每个环节都藏着细节。2.1 第一步先把 FormatContext 准备好很多人第一次写封装代码时直接用avformat_alloc_context()创建上下文然后立刻调avformat_new_stream最后写文件头发现各种奇怪问题。正确的做法先按封装格式分配输出上下文AVFormatContext *oc NULL; avformat_alloc_output_context2(oc, NULL, mp4, output.mp4); if (!oc) { // 一般说明 mp4 muxer 没编进去或者容器扩展名不识别 } AVStream *st avformat_new_stream(oc, NULL); if (!st) { // 内存分配失败正常情况极少出现 }注意avformat_alloc_output_context2只认容器名字符串、文件名扩展名这两种方式。它内部除了分配AVFormatContext还会把oformat指针挂到对应的AVOutputFormat上。这个oformat是否存在会直接影响avformat_new_stream内部部分初始化逻辑的走向所以不要为了省事绕过这步。2.2 第二步填 codecpar 而不是去操作 AVCodecContext新流创建出来后st-codecpar已经内部分配好了这是一个AVCodecParameters结构体用来描述流的编码参数。很多 2015 年之前的教程会让你去操作st-codec-codec_id那是旧版 API 的做法。现在AVStream里确实还保留了codec这个字段但内部实现里它已经逐渐边缘化所有封装器实际读取的参数都集中在codecpar里。填写参数的标准路径是从编码器上下文导出AVCodecContext *enc_ctx /* 获取你的编码器上下文 */; AVStream *st avformat_new_stream(oc, NULL); // 必须先执行参数导出再写文件头 avcodec_parameters_from_context(st-codecpar, enc_ctx); st-time_base enc_ctx-time_base; st-id oc-nb_streams - 1;如果你是直接拿解码器出来的AVCodecContext去转封装比如读取一个 MP4 里的 H.264 再原样封装进另一个 MP4同样用avcodec_parameters_from_context把解码器上下文里的 extradata、分辨率、帧率信息导入codecpar。这一步最容易遗漏的是 extradata。因为 H.264 的 SPS/PPS、HEVC 的 VPS/SPS/PPS 都存在AVCodecContext的extradata里不导出到codecparMP4 封装器在写avcCbox 时就会拿不到参数集最后生成的文件在播放器里大概率黑屏或者提示“无法解码”。2.3 第三步流级 ID 和私有数据不要忽略st-id这个字段在大多数容器里不等于流索引。比如 MPEG-TS 封装器要用它作为 PIDFLV 封装器会结合流的类型推 Script 标签MOV/MP4 封装器在写trak时会把st-id作为track_id。如果你不主动设置avformat_new_stream默认填 0多条流时可能引发封装器内部计算出问题。简单粗暴的做法是用oc-nb_streams - 1作为 id适用于大多数常规容器如果输出的是 TS 这类对 PID 有明确范围要求的格式请按协议分配。流创建之后还可以按需要在st-priv_data挂载封装器私有的流级数据。不过这个字段通常由具体 muxer 在avformat_write_header阶段自行创建普通使用者不看在里头放东西所以除非你在写自定义 muxer否则一般不直接碰它。2.4 第四步写头与流信息的最终确认avformat_write_header执行时muxer 会做很多修正动作。它可能调整st-time_base可能向st-codecpar补齐 tag 信息也可能为每条流生成内部索引。所以有些开发者发现明明我在创建流后设置了st-time_base {1, 1000}写完头打印出来却变成了{1, 90000}。这是符合预期的MP4/MOV 的影音轨存储时基往往统一折算到 90k。如果你需要精确知道写头后的流状态可以在avformat_write_header之后重新读取每个st-time_base并将所有待写入的包 pts/dts 按新时基重新换算。这也是为什么我不建议在创建流时把time_base设置成你觉得“合理”的值而是应该从编码器上下文复制。这样 muxer 在内部做换算的误差最小。3. 源码层面的初始化明细从公开行为逆推内部逻辑虽然不同版本的 FFmpeg 在avformat_new_stream里的实现略有差异但核心逻辑多年没有大改。通过观察函数返回后的流字段能反推它替我们做了哪些事。3.1 streams 数组的动态扩容AVFormatContext里维护了一个AVStream **streams数组和一个nb_streams计数。avformat_new_stream的第一个动作就是申请一块新的AVStream内存然后把指针挂进数组同时让nb_streams自增。它内部会根据当前数组容量判断是否需要av_realloc否则每次创建流都从 0 分配多条流时性能会比较差。这也意味着你在创建大量流后之前保存的st指针并不会因数组扩容而失效因为st指向的堆内存是独立的变的只是s-streams这个指针数组本身。3.2 codecpar 的默认分配与外部编解码器参数函数内部比较关键的一步是给st-codecpar分配一个干净的AVCodecParameters并给st-internal分配AVStreamInternal。internal是内部状态结构不对外公开字段解封装器在解析流信息时用的很多状态都藏在里面。当第二个参数c不为NULL时函数会把st-codecpar-codec_id和codec_type直接设成c-id和c-type省去调用后手动补写的步骤。但是要注意函数不会自动填充分辨率、采样率、声道数这类“更细”的参数这些还是要依赖后续avcodec_parameters_from_context或者手动赋值。3.3 它没有做的事时间基、元数据、side data 全都不管avformat_new_stream返回后st-time_base保持默认值{0, 0}或极少数平台上可能是{1, 1000000}之类的初始化值它绝不会去读编码器的time_base。这一点新手特别容易误解以为创建流之后时间基就绪可以直接往里塞 pts。实际上在 muxing 场景里你必须显式设置st-time_base在解封装场景里则由 demuxer 在解析到具体帧后回填。同时st-metadata、st-disposition、st-avg_frame_rate、st-sample_aspect_ratio、st-start_time、st-duration这些字段也全部保持零值需要业务层根据实际情况补充。你可以理解为avformat_new_stream只是“把流的位置腾出来了把基本骨架搭好了”更上层的语义信息全都需要后面的代码去填充。这就引出一个工程上的建议最好把所有流的公共初始化封装进一个函数统一设置time_base、id、disposition等字段避免每个调用位置复制粘贴。4. 解复用侧的同一个函数demuxer 大量使用它很多做封装开发的同学对avformat_new_stream在 muxing 里的调用方式很熟却容易忽略它在 demuxing 侧同样处于核心位置。当你在一个 MP4 文件里读到一个moovbox 中的多个trak或者解析 MKV 的多个轨道时libavformat 内部的 demuxer 会调用avformat_new_stream为每个轨道创建对应的AVStream。与 muxing 侧不同demuxing 侧调用这个函数后随后会紧跟着设置大量流级字段st-codecpar里的编码类型、分辨率、帧率、采样率st-time_base一般是容器的时间基st-start_time和st-durationav_stream_add_side_data加入流级别的附加信息普通应用开发者通常不会直接调用它但理解这条路径能帮你更准确地读懂avformat_find_stream_info之后的状态。很多时候我们看到AVFormatContext里流的字段非常完整那不是因为avformat_new_stream一次性填完而是 demuxer 后续逐个灌入的。所以如果你将来要写自己的 demuxer记住“创建流之后解析自己的轨道信息然后填充AVStream字段”这条路。另外有些老工程里还能看到av_new_stream的写法这是旧版本的 API如今已经被avformat_new_stream取代。最大的区别之一就是新 API 多了一个可选AVCodec *参数且内部对AVStreamInternal的初始化更完整。新代码里不要再使用av_new_stream否则几乎必踩结构体未初始化的坑。5. 实战避坑我在项目里总结的六个教训直接贴 Python 转码脚本式的工具代码没有意义真正的经验往往来自这些细节造成的线上故障。这里列出几个我在封装和转封装项目里反复遇到的高频问题。5.1 返回的 st 是 NULL 却不检查avformat_new_stream在绝大多数情况下不会失败因为只是堆内存分配。但一旦遇到内存被耗尽、或者AVFormatContext处于只读状态等异常它会返回 NULL。如果业务代码没做判空就往下执行st-codecpar-codec_type ...结果就是段错误。我见过有同事排查了半天最后发现原因就是连续创建大量流导致内存分配失败而代码完全没做保护。建议创建流的函数返回后立即判空。5.2 重点忘记调用 avcodec_parameters_from_context如果在创建流之后手动往codecpar里一个一个字段赋值特别容易漏掉 extradata。这个字段在调试时打ffprobe不一定看得出来因为容器封装时缺了 extradata文件照样生成但播放器打开时可能长时间黑屏、或者只有声音没有画面。如果你是从编码器拿参数请务必使用avcodec_parameters_from_context而不是手动赋值手动赋值时也要记得把extradata和extradata_size一并拷贝。5.3 写了 header 之后还在改 codecparavformat_write_header执行后很多 muxer 会基于codecpar构建内部的索引、box 结构。这时候再去修改codecpar可能导致文件头信息与实际流数据不一致播放器要么显示错误时长要么直接无法 seek。正确的做法是在写头之前把codecpar全部设置完毕如果中途确实需要调整也要在写头前完成。5.4 混淆 st-time_base 与编码器的 time_base创建流时把st-time_base设置成{1, 1000}但编码器实际输出帧率是 25fps写入包的 pts 是{0, 40, 80, ...}。此时 muxer 会把流的时基转换成对应容器的存储时基但这个转换依赖 pts 的解释。如果你使用的 pts 刻度与st-time_base对不上最终生成文件的播放速度可能变成 1.5 倍速、0.5 倍速甚至出现大量“Non-monotonous DTS”警告。最好的做法是从编码器上下文直接拷贝time_base或者写头后按 muxer 返回的时基重新换算 pts。5.5 在多个输出上下文之间误用 st有的工具支持“一个输入同时转多个格式输出”代码里容易图省事把第一次avformat_new_stream(oc1, NULL)得到的AVStream指针直接塞进第二个输出上下文使用。这是典型的非法操作每个AVStream从属于唯一的AVFormatContext跨上下文使用会导致内部索引错乱和崩溃。正确做法是在每个输出上下文里各自创建新流再分别填充参数。5.6 不同容器的流级约束MKV 和多大部分容器对流数量没严格限制但 MPEG-TS 在传输流里同时只能有特定类型的 PID 分配MOV 对音视频交错顺序也有自己的规则。因此创建流时建议在代码里做一层约束校验比如 TS 输出时检查视频流数量有没有超过协议允许范围。这类问题不是avformat_new_stream本身的问题但发生故障时定位到“流创建阶段是否错放参数”是非常必要的排查路径。6. 自定义封装器视角流创建之后还能挂什么如果你手头要做的是私有容器格式或者要对现有 muxer 做二次封装那avformat_new_stream之后的操作会更复杂一些。这里分享两种常见的扩展用法。6.1 基于 st-priv_data 做每流私有状态管理自定义AVOutputFormat时写write_header回调里你可以遍历s-streams为每个st-priv_data创建自定义状态结构体。例如你的私有格式要求每个轨道的包交织深度不同那可以在priv_data里保存每流当前的写入文件偏移、上次时间戳等。这样write_packet回调里通过st-index索引当前的AVStream再通过st-priv_data读取和更新状态能够把多流编码逻辑很好地隔离。注意priv_data的生命周期同样由AVFormatContext管理在deinit回调里做释放不能自己随便av_free。6.2 使用 side data 追加流级附加信息avformat_new_stream创建出的流默认没有附加数据。如果你想向输出容器里写入像旋转角度、HDR 动态元数据、回放增益这类信息需要在流创建后调用AVStream *st avformat_new_stream(oc, NULL); AVStreamSideData *sd av_stream_new_side_data(st, AV_PKT_DATA_DISPLAYMATRIX, 36);av_stream_new_side_data会把数据挂到st-side_data数组之后封装器在处理这个流时就能看到该信息。这个动作需要在写文件头之前完成否则 muxer 可能认为流侧没有任何附加信息直接把 header 里的相关 box 省略掉了。这个机制也是我自己实现私有封装器时最喜欢用的扩展点——它不需要改动容器格式便能给每个轨道挂额外的业务字段。6.3 一条流在生命周期里的完整职责链从avformat_new_stream创建开始一条流的职责链路大致是创建流节点确定编码参数设置流时基和流 ID补充 side data、disposition、元数据交 muxer 写文件头打包写入期间被 muxer 引用写完文件尾整个上下文释放把这条链路想清楚再回头看avformat_new_stream的功能边界就清晰了它只负责“第一步”里最基本的节点分配与参数结构初始化后面的每一步都靠后续代码主动去做。工程实践里遇到问题先分清是流创建阶段没设好还是后续写头阶段被覆盖往往能节省很多排查时间。由于这函数在每一层都牵涉到很多具体的容器规则单纯看文档不够我建议你实际动手把 FFmpeg 源码里的libavformat/utils.c中的定义和libavformat/mux.c里的write_header流程对照读一遍。这种“现场考古”式的学习方式比看任何二手教程都更能帮你建立准确的直觉。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →