☰
RTSP转封装实践:用libavformat将视频流落盘为mp4/avi/flv
2026/10/14 1:24:26 网站建设 项目流程

简介:FFmpeg RTSP流封装源码是一份将实时RTSP流转为MP4、AVI、FLV三种容器格式的C++实现,面向需要直接集成封装逻辑的音视频开发者,尤其适用于网络摄像头、实时监控等场景。RAR压缩包内共2个文件,分别为核心实现videotransform.cpp与头文件videotransform.h,整体约2KB,代码精简,便于快速阅读和移植;其中cpp文件承载完整转换流程,h文件提供接口声明。源码涵盖FFmpeg上下文初始化、输入输出参数设置、流打开与创建、数据读取写入以及资源释放等关键步骤,可清晰理解RTSP到三种容器格式的封装差异,也能在此基础上扩展自定义编码参数、滤镜或异常处理。该实现不依赖命令行工具,适合嵌入到自有播放器、录制程序或监控平台中,也可作为学习FFmpeg封装API的入门示例。已有7177人学习下载,说明该示例在同类需求中具有较高的参考价值。

1. 从监控项目被客户一句“要能回放”逼出来的 RTSP 转封装源码

做某园区安防平台对接时,客户要求海康、大华等厂商的 RTSP 视频流不仅能实时预览,还得落盘成 mp4、avi、flv 三种格式供不同业务端回放。当时直接拿命令行 ffmpeg 顶了一阵子,结果摄像头一多、断流一频繁,shell 进程管理就变得一团糟:有的进程僵死,有的文件时间轴错乱,后来干脆把 ffmpeg 的 libavformat 系列库嵌进 C++ 服务里,自己写调度、自己管生命周期,才算把这件事彻底解决。这篇文章就是把你把“RTSP 拉流 — 转封装 — 落盘 mp4/avi/flv”这一整条链路拆开,从 API 选型到三个封装格式的差异、再到时间戳归一化和断流恢复的坑,一次讲透。适合正在做视频监控录像、流媒体网关或者直播点播平台的开发者,尤其是那些被“为什么 mp4 能播 avi 黑屏”“为什么 flv 没法拖动进度条”这类问题卡住的人。

2. 为什么不能直接拿命令行 ffmpeg 硬扛:源码嵌入的选型理由

2.1 进程级调用的三个致命伤

早期方案是每个摄像头拉起一个ffmpeg -i rtsp://... -c copy out.mp4子进程,表面上看能跑,但生产环境一旦超过 30 路,问题立刻暴露:首先是进程退出码不可靠,摄像头主动断开时 ffmpeg 有时返回 0、有时返回 1,调度端根本没法区分“正常结束”和“异常断流”;其次是每个进程都要重新解析 RTSP 的 SDP、重新协商传输参数,这部分开销虽然不大,但叠加起来占用的文件描述符和内存不容小觑;最麻烦的是无法精细控制封装细节,比如 mp4 的-movflags需要边写边生成 moov 还是等所有数据写完再生成,命令行参数只能给个大概,遇到 seek 需求就无能为力。

2.2 libavformat 库调用的核心链路

改用源码嵌入后,整个流程变成了一套标准调用链:先用avformat_open_input打开 RTSP 地址,内部完成 RTSP over TCP/UDP 的协商;然后avformat_find_stream_info拿到流信息,这一步对 RTSP 尤为重要,因为 SDP 里带的参数往往不完整,必须靠读几帧探测真实的编码参数;接下来为每个输出格式创建AVFormatContext,调用avformat_write_header写封装头;再进入循环用av_read_frame从输入读包、av_interleaved_write_frame写入输出;最后av_write_trailer收尾。这套链路和命令行 ffmpeg 内部逻辑一致,但每个环节都能拿到返回值、插自己的处理逻辑。

AVFormatContext *in_ctx = NULL; int ret = avformat_open_input(&in_ctx, url, NULL, NULL); if (ret < 0) { // 打印 av_err2str(ret) 定位是网络不通还是认证失败 return ret; } ret = avformat_find_stream_info(in_ctx, NULL); if (ret < 0) { // RTSP 流这里可能因为探测超时失败,建议加大 probesize avformat_close_input(&in_ctx); return ret; }

参数说明:avformat_open_input的第三个参数传 NULL 表示按文件扩展名或协议自动探测;RTSP 场景下必须注意AVFormatContext里的probesize,默认值对本地文件够用,但网络流延迟大时建议手动设成 5MB 以上,否则探测阶段可能只读了 500KB 就放弃了。另外avformat_find_stream_info内部会解码部分帧来补充流参数,传给它的AVDictionary可以塞probesize和max_analyze_duration,前者控制读取上限,后者控制时间上限,两只都调大才能应对高延迟的无线摄像头。

2.3 三种封装格式的容器特性决定了写入方式不一样

mp4 基于 ISO BMFF,moov box 可以前置也可以后置;avi 是 RIFF 结构,索引块放在文件尾部;flv 则是顺序写入 tag 的流式格式。这导致同一个av_interleaved_write_frame调用,在三种格式内部的行为完全不同:mp4 需要维护 chunk offset 和 sample size 表,avi 需要累计每个 stream 的索引项,flv 则按 tag 顺序直接落盘。所以源码里必须对输出格式做分支处理,比如 mp4 可以设置av_dict_set(&opts, "movflags", "frag_keyframe+empty_moov", 0)生成分段式 mp4,每几秒一个 moof,这样即使程序崩溃,已写入的部分也能播放;avi 则要确保写完所有包后再调av_write_trailer,中途退出会导致索引缺失、播放器无法 seek。

AVFormatContext *out_ctx = NULL; avformat_alloc_output_context2(&out_ctx, NULL, NULL, "output.mp4"); // 手动指定输出格式为 mp4,避免 ffmpeg 按扩展名猜错 AVDictionary *opts = NULL; av_dict_set(&opts, "movflags", "frag_keyframe+empty_moov", 0); if (avformat_write_header(out_ctx, &opts) < 0) { // 头写入失败通常是编码参数不匹配,比如 H.264 的 extradata 为空 return -1; }

注意avformat_alloc_output_context2的第二三个参数,传 NULL 让 ffmpeg 根据文件名后缀推断;但更保险的做法是直接指定"mp4"、"avi"、"flv",因为有的环境里扩展名推断可能走到错误的 muxer。av_dict_set的第三个参数设为 1 表示若 key 已存在则覆盖,这里的frag_keyframe+empty_moov是 mp4 独有的,avi 和 flv 设置了这个 flag 会被忽略,不会报错但也不会生效。

3. 拿到 RTSP 流只是开始:解复用与时间戳对齐的实现细节

3.1 av_read_frame 返回的到底是裸流还是复合流

RTSP 解复用器内部已经把 RTP 包重组成了完整的视频帧或音频帧,av_read_frame返回的AVPacket的pts、dts和duration由 RTP 时间戳换算而来。这里有个关键点:RTSP 的 RTP 时间戳基准频率不一定是 90000,H.264 默认 90kHz,但音频可能是 44100 或 48000,avformat_find_stream_info之后每个流的time_base已经归一化,比如视频流time_base可能是{1, 90000},音频流是{1, 48000}。写入输出时 muxer 会根据目标格式要求自动做时间基转换,但前提是你在拷贝AVPacket时不要手工修改pts和dts,否则会出现时间轴跳变。

AVPacket pkt; av_init_packet(&pkt); int stream_index = -1; while (av_read_frame(in_ctx, &pkt) >= 0) { stream_index = pkt.stream_index; if (stream_index == video_stream_idx) { // 转封装场景直接拷贝,不需要 av_packet_rescale_ts // 因为 muxer 会在内部处理时基转换 pkt.pts = av_rescale_q(pkt.pts, in_stream->time_base, out_stream->time_base); pkt.dts = av_rescale_q(pkt.dts, in_stream->time_base, out_stream->time_base); pkt.duration = av_rescale_q(pkt.duration, in_stream->time_base, out_stream->time_base); } av_interleaved_write_frame(out_ctx, &pkt); av_packet_unref(&pkt); }

逻辑说明:这段代码做了显式的av_rescale_q,虽然 muxer 内部也会做,但显式转换有个好处——你可以在此处打断点确认时间戳数值是否符合预期。比如某路摄像头视频流time_base是{1, 90000},pts累加值为 3600,对应 0.04 秒;转成 mp4 的{1, 15360}时间基后,av_rescale_q会计算 3600 * 15360 / 90000 = 614.4,四舍五入 614,这个数值就是写入 mp4 文件里的实际时间戳。如果发现 614 对应的播放时刻不对,说明原始 RTP 时间戳本身有问题,不是转换的锅。

3.2 时间戳归一化:从 K 线到绝对时间轴

监控摄像头的 RTSP 流往往从开机才开始推流,RTP 时间戳从 0 累加,但中途摄像头重启后 RTP 时间戳会重新从 0 开始,如果直接写入输出,播放器会把第二段数据当成 0 秒附近的数据,导致画面跳动甚至无法播放。因此必须做归一化:记录第一帧的pts作为基准,后续所有帧的pts减去这个基准值。

int64_t start_pts = AV_NOPTS_VALUE; // ... if (start_pts == AV_NOPTS_VALUE) { start_pts = pkt.pts; } int64_t normalized_pts = pkt.pts - start_pts; pkt.pts = normalized_pts; pkt.dts = normalized_pts;

参数说明:start_pts必须在第一次进入读写循环时赋值,且要区分每个流单独维护,不能所有流公用一个基准,因为视频流和音频流的time_base可能不同,减法操作在不同量纲下做会直接错乱。这段逻辑对 flv 尤其重要,因为 flv muxer 内部要求时间戳单调递增,如果第二段流的pts突然跳回 0,muxer 会报Non-monotonic timestamp错误并丢弃帧。

3.3 判断视频流和音频流的索引

从in_ctx拿到流的数量、类型和编码参数之后,要分别记录视频流和音频流的stream_index。判断依据是codecpar->codec_type,不是codec_id。这里有个常见误区:codec_id等AV_CODEC_ID_H264只能说明编码格式,不能说明流类型;有些设备会通过 RTSP 传输 H.264 编码的屏幕流但仍标记为视频流,而音频轨可能是 AAC 或者 G.711。建索引的代码很简单,关键是后续写入时out_ctx->streams[i]和in_ctx->streams[j]的对应关系要一致。

int video_stream_idx = -1, audio_stream_idx = -1; for (unsigned int i = 0; i < in_ctx->nb_streams; i++) { if (in_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && video_stream_idx == -1) { video_stream_idx = i; } else if (in_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO && audio_stream_idx == -1) { audio_stream_idx = i; } } if (video_stream_idx == -1) { // 没有视频轨的 RTSP 流直接报错,监控场景不可能接受纯音频文件 return -1; }

这段代码额外做了个判断:如果只有视频没有音频,仍然可以封装;但如果连视频都没有,整个任务就没有意义,提前退出而不是等到写入时报错。这里要注意nb_streams的遍历顺序不保证视频在前,所以不要用if (i == 0)判断主码流,这在多轨流里会翻车。

4. 三格式落地:mp4、avi、flv 的写头、写包、收尾全流程

4.1 mp4:分段写入还是单文件写入

mp4 是需求里最常见的输出格式,因为它兼容性最好,浏览器直接<video>标签就能播,手机端也无需额外解码器。写 mp4 时建议用分段模式,也就是frag_keyframe+empty_moov,每个关键帧开始一个新的 fragment,文件里会出现多个moof和mdat。这种模式的好处是写入过程中断不会损坏整个文件,坏掉的部分只有当前这个 fragment;缺点是文件体积会比单文件模式大 2%~5%,因为每个 fragment 都要带 box 头。如果对体积敏感,可以用-movflags faststart让 moov 前置,但代价是必须缓冲足够的数据才能开始写文件,延迟高。

static int open_output(AVFormatContext *out_ctx, const char *filename, const char *format) { int ret = avio_open(&out_ctx->pb, filename, AVIO_FLAG_WRITE); if (ret < 0) { return ret; } AVDictionary *opts = NULL; if (strcmp(format, "mp4") == 0) { av_dict_set(&opts, "movflags", "frag_keyframe+empty_moov", 0); av_dict_set(&opts, "flags", "negative_ts", 0); } ret = avformat_write_header(out_ctx, &opts); av_dict_free(&opts); return ret; }

逻辑说明:avio_open建立文件输出通道,这一步会创建或截断目标文件。flags=negative_ts是 mp4 的另一个关键选项,它允许时间戳出现负值——这个场景发生在输入流第一帧不是关键帧时,pts可能小于 0。如果不加这个 flag,mp4 muxer 会直接把负时间戳帧丢弃,导致视频开头黑屏几秒。另外这段代码里的opts字典在avformat_write_header内部会被消费,所以写完后必须av_dict_free释放,否则内存泄漏。

4.2 avi:索引在尾部,断流即报废

avi 的封装结构决定了它必须等所有数据写完后,在文件尾部生成索引块,播放器通过索引才能定位到每个帧的偏移。这意味着如果写入过程中程序崩溃或者摄像头断流没等av_write_trailer就被杀掉,生成的文件就是没有索引的“半成品”,VLC 可能还能靠暴力扫描播放,但大部分播放器直接报“文件损坏”。所以 avi 场景下必须有兜底逻辑:写入过程定期刷盘,并且捕获断流信号走优雅退出。

int write_avi_trailer(AVFormatContext *out_ctx) { int ret = av_write_trailer(out_ctx); if (ret < 0) { // 常见错误是 AVERROR_INVALIDDATA,说明有包没对齐 // 此时需要检查每个流最后一帧的 duration 是否为 0 return ret; } ret = avio_closep(&out_ctx->pb); return ret; }

参数说明:av_write_trailer对 avi 而言就是计算并写入 RIFF 的movi块长度和idx1索引块。这里有个隐藏要求:在调用av_write_trailer之前,必须保证所有写进去的包都有完整的duration和正确的dts顺序。如果某个音频包的duration是 0,avi muxer 计算索引时会对不齐,最终文件播放到该处会卡顿。

提示:avi 封装不支持 B 帧重排的流吗?——支持,但要求输入的dts严格递增。RTSP 的 H.264 流如果带了 B 帧,拉流端拿到的包顺序已经是解码顺序,写入时直接按序写即可,不需要额外重排。

4.3 flv:直播点播通用的流式容器

flv 的封装结构最简单,每个 tag 前面有 11 字节的 tag header,里面包含时间戳和数据类型,不存在索引块,所以天然支持边写边播。这使其成为直播场景的首选,也常用于网页播放器(配合 http-flv 协议)。但 flv 有两个限制:一是对编码格式有硬性要求,视频只支持 H.264 和 H.263,音频只支持 AAC 和 MP3;二是时间戳必须是整数毫秒且单调递增,任何一帧出现回退都会被 muxer 拒绝。

int write_flv_header(AVFormatContext *out_ctx) { AVDictionary *opts = NULL; // flv muxer 的 flvflags 选项可以控制是否写入关键帧索引 // 关键帧索引写入后,播放器能快速 seek,代价是文件体积略增 av_dict_set(&opts, "flvflags", "add_keyframe_index", 0); int ret = avformat_write_header(out_ctx, &opts); av_dict_free(&opts); return ret; }

注意add_keyframe_index这个 flag 在部分 ffmpeg 版本里默认不启用,不启用的话播放器仍然能播,但拖动进度条时会从头扫到目标位置,文件大的话卡顿明显。如果你的 flv 是给点播用的,建议开启;如果是纯直播用,开不开都没区别。另外 flv 的音频 tag 有严格的格式要求:音频数据必须包含 AudioSpecificConfig,从 RTSP 拉到的 AAC 流里 SDP 可能带了fmtp参数包含这个配置,但也可能不带,此时需要用avcodec_parameters_from_context生成一份。

AVCodecParameters *par = out_ctx->streams[audio_stream_idx]->codecpar; uint8_t *extradata = par->extradata; int extradata_size = par->extradata_size; if (extradata_size == 0) { // 从解码器上下文补一份 extradata // 具体做法:avcodec_parameters_to_context 之后调 avcodec_open2 // 再拿 codec_ctx->extradata 回填 }

逻辑说明:extradata为空是 flv 封装最常见的坑,根源在于 RTSP SDP 里的fmtp字段没有被 ffmpeg 解析进codecpar->extradata。补数据的手段是初始化一个解码器上下文,把codecpar转进去,打开解码器,然后从codec_ctx->extradata拷回来;或者更省事的做法是直接用av_bitstream_filter_filter处理,但版本兼容性差,不推荐。

4.4 三种格式的选型对比

维度mp4aviflv
索引机制moov box(可前置/分段)尾部 idx1 索引无索引,按 tag 顺序
断流容错分段模式容错高非正常退出文件报废天然容错
浏览器兼容原生支持需插件需 flv.js
适合场景录像回放、点播老系统对接直播、http-flv
时间戳要求允许负值,需配置要求 dts 递增严格单调递增毫秒

这个表是从实际项目里总结出来的,不是文档里抄的。avi 在监控行业里的存量系统仍有使用——有些老旧平台只认 avi 文件,无法升级,所以即便它容错最差也得支持。flv 则基本只用在流媒体转发这一层,落盘需求很少。

5. 避坑指南:RTSP 转封装最容易翻车的六个场景

5.1 现象:转换出来的 mp4 文件打不开,报“moov atom not found”

原因:avformat_write_header和av_write_trailer之间写入的数据量太少,或者av_write_trailer没有被调用。RTSP 断流后主循环退出,有些开发者直接销毁上下文,跳过了 trailer 步骤。解决:在循环退出后显式调用av_write_trailer,并用atexit或在信号处理函数里兜底;如果是分段 mp4 模式,至少也要调用 trailer 来收尾最后一个 fragment。

// 错误写法 while (av_read_frame(...) >= 0) { ... } avformat_free_context(out_ctx); // 正确写法 while (av_read_frame(...) >= 0) { ... } av_write_trailer(out_ctx); avio_closep(&out_ctx->pb); avformat_free_context(out_ctx);

5.2 现象:flv 文件在网页里播放没有声音,但视频正常

原因:音频编码格式不是 AAC,而是 G.711 或 PCM,网页播放器不支持这些编码。RTSP 里很多摄像头音频默认是 G.711A,录进 flv 后需要转码为 AAC,或者用 WAV 容器替代。解决:在封装前检查codecpar->codec_id,如果是AV_CODEC_ID_PCM_ALAW或AV_CODEC_ID_PCM_MULAW,需要插入音频重采样和编码逻辑,不能直接-c copy。

if (audio_codec_id == AV_CODEC_ID_PCM_ALAW || audio_codec_id == AV_CODEC_ID_PCM_MULAW) { // 必须转码,flv 官方规范只支持 AAC 和 MP3 // 方案:添加一个 AAC 编码器,对音频帧做重采样后编码写入 }

5.3 现象:avi 文件超过 4GB 后播放到一半卡死

原因:AVI 的 RIFF 结构在旧规范里使用 32 位偏移,最大只能寻址 4GB;OpenDML 扩展虽然支持更大文件,但 ffmpeg 的 avi muxer 必须显式启用才写 OpenDML 索引。解决:设置AVFormatContext的pb为AVIO_FLAG_WRITE时,avformat_write_header前添加av_dict_set(&opts, "use_odml", "1", 0)启用 OpenDML。注意别在文件中途才决定开启,必须在写头之前设置。

AVDictionary *opts = NULL; if (strcmp(format, "avi") == 0) { av_dict_set(&opts, "use_odml", "1", 0); } avformat_write_header(out_ctx, &opts);

5.4 现象:RTSP 断流重连后,录像文件里出现十几秒黑屏

原因:重连之后摄像头重新发送了 SPS/PPS,但输出文件的extradata还是旧的,新的关键帧带的新参数没有同步到容器层。mp4 的 avcC box 里保存的 SPS/PPS 在写头时就固定了,流的参数一旦变化,播放器用旧参数解码新帧就会黑屏。解决:在每次读到AV_PACKET_FLAG_KEY的帧前,检查codecpar->extradata_size是否有变化;如果有,调用avformat_write_header重新写头(mp4 分段模式支持重新写头),或者干脆每个关键帧间隔重置输出上下文。

5.5 现象:调用av_interleaved_write_frame返回AVERROR_INVALIDDATA

原因:某个流的dts不单调递增,通常是 RTSP 的 RTP 包乱序导致,或者音频流和视频流的pts基准不一致。解决:在写入前对每个流的包做排序缓冲,按dts排序后再写入;更简单的做法是给输入上下文设置AVFMT_FLAG_DISCARD_CORRUPT,丢弃损坏帧,但会牺牲一部分数据完整性。

AVDictionary *in_opts = NULL; av_dict_set(&in_opts, "rtsp_transport", "tcp", 0); av_dict_set(&in_opts, "stimeout", "5000000", 0); // TCP 传输能减少乱序概率,但延迟更高

5.6 现象:程序长期运行后内存暴涨,疑似泄漏

原因:av_read_frame返回的AVPacket没有调用av_packet_unref释放内存,尤其是 RTSP 流的包内部带有 RTP 重组缓冲区,不释放会越积越多。解决:每次循环末尾必须av_packet_unref(&pkt),并且确认没有把pkt的指针保存到其他地方——av_interleaved_write_frame内部会拷贝数据,所以写入后立即 unref 是安全的。

6. 进阶技巧:H.264 关键帧间隔与 GOP 控制,减少首开延迟

摄像头 RTSP 流的 GOP 大小由设备端编码器决定,常见值是 50 帧(2 秒 @25fps)或 100 帧(4 秒 @25fps)。录像文件首开延迟和 seek 精度都受这个值影响:GOP 越大,关键帧间隔越长,打开文件后要等更久才能解码出第一帧画面。但修改 GOP 通常需要通过摄像头的 ISAPI/ONVIF 接口下发配置,程序里不直接可控;我们能在推流侧做的,是调整输出封装对关键帧的处理方式。

flv 的add_keyframe_index和 mp4 的frag_keyframe都依赖“找到关键帧就切分/建索引”的逻辑,而这背后的前提是输入帧的AV_PKT_FLAG_KEY标志位正确。H.264 的 RTP 包在重组时,ffmpeg 会根据 NAL 单元类型判断是否为 IDR 帧并设置这个标志,但某些非标摄像头会在 RTP 的payload_type或扩展头里做手脚,导致 ffmpeg 无法识别关键帧,此时整个文件的所有帧都被标记为非关键帧,无论你怎么调 muxer 参数都白搭。

排查方法很简单,在读取循环里打印每帧的flags:

if (pkt.flags & AV_PKT_FLAG_KEY) { av_log(NULL, AV_LOG_INFO, "keyframe at pts=%lld\n", pkt.pts); }

如果跑了 10 秒一帧 keyframe 都没打出来,说明 RTP 解析有问题。这时检查输入上下文设置的rtsp_flags是否包含了listen模式或者prefer_tcp,通常切到 TCP 传输能解决一部分丢包导致的 NAL 不完整。还有人遇到过 H.264 的 SPS/PPS 不随流发送、只在 SDP 里带一份的情况,ffmpeg 解析后存进extradata,这种情况下每帧都不带关键帧标志也是正常的——因为 SPS/PPS 不在 RTP 流里,解码器拿不到参数就无法判别 IDR。解决办法是手动从extradata里解析 SPS/PPS,并把它作为AVPacket的 side data 附加到第一个关键帧上。

// 手动附加 extradata 到关键帧 if (pkt.flags & AV_PKT_FLAG_KEY) { size_t side_data_size = par->extradata_size; uint8_t *side_data = av_packet_new_side_data(&pkt, AV_PKT_DATA_NEW_EXTRADATA, side_data_size); memcpy(side_data, par->extradata, side_data_size); }

这段代码对 mp4 封装尤其有用,因为 mp4 的写头逻辑有机会在关键帧处重新写入 avcC box,把新的 extradata 同步进去。avi 和 flv 对 extradata 变化不敏感,但附加 side data 也不会有害处。实际操作中还有一条实用习惯:从那次监控项目之后,每次写完录像文件都会强制再读一遍文件头和时间戳序列,确认关键帧间隔符合预期再交付给客户。这个动作虽然耗时几秒,但能拦截大部分“本地能播,客户不能播”的诡异问题。希望这篇笔记能帮你在做 RTSP 转封装时少走几个弯路,这套代码逻辑我后来又复用在了两台不同厂商的设备上,只是时间戳归一化的阈值做了点调整而已。

本文还有配套的精品资源,点击获取

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

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

立即咨询