☰
FFmpeg avformat_new_stream 剖析:AVStream 创建与封装核心逻辑详解
2026/9/29 16:48:20 网站建设 项目流程

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里,不导出到codecpar,MP4 封装器在写avcCbox 时就会拿不到参数集,最后生成的文件在播放器里大概率黑屏或者提示“无法解码”。

2.3 第三步:流级 ID 和私有数据不要忽略

st->id这个字段在大多数容器里不等于流索引。比如 MPEG-TS 封装器要用它作为 PID,FLV 封装器会结合流的类型推 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->duration
  • av_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 之后还在改 codecpar

avformat_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创建开始,一条流的职责链路大致是:

  1. 创建流节点,确定编码参数
  2. 设置流时基和流 ID
  3. 补充 side data、disposition、元数据
  4. 交 muxer 写文件头
  5. 打包写入期间被 muxer 引用
  6. 写完文件尾,整个上下文释放

把这条链路想清楚,再回头看avformat_new_stream的功能边界就清晰了:它只负责“第一步”里最基本的节点分配与参数结构初始化,后面的每一步都靠后续代码主动去做。工程实践里遇到问题,先分清是流创建阶段没设好,还是后续写头阶段被覆盖,往往能节省很多排查时间。

由于这函数在每一层都牵涉到很多具体的容器规则,单纯看文档不够,我建议你实际动手把 FFmpeg 源码里的libavformat/utils.c中的定义和libavformat/mux.c里的write_header流程对照读一遍。这种“现场考古”式的学习方式,比看任何二手教程都更能帮你建立准确的直觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询