资讯动态

RTP流合成MP4:H264与AAC解包、时间戳映射及Box封装实战

发布时间:2026/10/20 7:38:39 来源:尧图企业网站定制
简介基于H.264与AAC的RTP媒体流合成MP4的实现方案面向流媒体开发者和音视频学习者解决从RTP数据包中提取H.264视频与AAC音频并封装为MP4文件的常见需求。资源共31个文件以C语言源码和头文件为主体涵盖RTP接收、解码、封装等模块设计同时提供Makefile构建脚本、SDP会话描述文件、示例MP4输出及辅助工具整个压缩包约197KB体量轻巧、结构清晰便于直接阅读和二次开发。目前已有487人学习下载。阅读该方案可系统掌握RTP负载解析、H.264分片重组与AAC帧处理、MP4封装格式等关键环节配合SDP和示例输出能够快速验证链路完整性。对从事实时音视频传输、播放器开发或流媒体协议研究的技术人员而言是一份可直接对照调试的实用参考适合具备一定网络编程基础并希望深入理解MP4与RTP结合机制的开发者。1. RTP 流合成 MP4明明都是 H264 和 AAC为什么不能直接改后缀名拿到一段抓包工具导出的 RTP 原始流扩展名改成 .mp4播放器直接拒绝播放提示“文件损坏”。这个反直觉的现象每天都在发生RTP 负载里明明装的就是 H264 NAL 单元和 AAC 音频帧跟 MP4 文件里的数据本质上是同一种东西为什么换了个壳就播不了原因是 RTP 是流式传输协议它把媒体数据切成小块、打上时间戳、按实时性要求往外丢而 MP4 是文件容器要求数据按固定 Box 结构排列并且必须有 moov 这样的索引区来告诉播放器“哪一帧在哪个字节偏移、时长多少”。两者之间的转换不是简单的重命名而是“拆包 → 重组 NAL → 对齐时间戳 → 写索引”的全套工程。搞懂这条链路你才能写出稳定不翻车的 RTP 转 MP4 工具也才能真正理解视频封装容器和流媒体协议之间的底层关系。2. RTP 解包先从传输层的“果酱铺”里把 H264 和 AAC 的完整帧抠出来2.1 H264 在 RTP 里的三种包装单包、FU-A、STAP-AH264 在 RTP 里并不总是“一个 NAL 单元一个包”。因为 RTP 底层的 UDP 包通常限制在 1460 字节左右以太网 MTU 1500 减去 IP 头 20 和 UDP 头 8而一个视频关键帧的 NAL 经常是几十 KB 甚至上百 KB必须分片。H264 的 RTP 封装规范定义了三种载荷类型单 NAL 单元Single NAL、分片单元FU-AFragmentation Unit、聚合包STAP-ASingle-Time Aggregation Packet。其中 FU-A 是最常见的长帧处理方式STAP-A 用来把 SPS/PPS 这类小参数集拼在一个包里。一个新视频帧的第一个分片包长这样RTP 头之后是 FU indicator 字节和 FU header 字节。FU indicator 的低 5 位是 NAL typeFU header 的 S、E、R 三个标志位分别表示分片起始、结束和保留位。把这两个字节拿掉再把 FU header 里的 NAL type 写回 FU indicator 的低 5 位就能得到完整的 NAL head。这种位级别的还原操作如果写错一个 bit解码器立刻花屏。2.2 重组 FU-A 分片判空、判断开始、判断结束缺一不可用 Python 写一个重组逻辑关键步骤就三行流程def rtp_to_annexb(rtp_payload, nal_header_size2): # 第一个字节是 FU indicator第二个是 FU header fu_indicator rtp_payload[0] fu_header rtp_payload[1] nalu_type fu_header 0x1F if fu_header 0x80: # S bit 1分片起始 # 把 nalu_type 写回 fu_indicator 的低 5 位 reconstructed bytes([(fu_indicator 0xE0) | nalu_type]) # 真正的 NAL 数据从第三个字节开始拼接 return b\x00\x00\x00\x01 reconstructed rtp_payload[2:] elif fu_header 0x40: # E bit 1分片结束 # 此时不需要再拼接 NAL 头直接接续数据 return rtp_payload[2:] else: # 中间分片整包都是负载数据 return rtp_payload[2:]这个函数每次只处理一个 RTP 包返回的是可以扔给 H264 解码器的 AnnexB 格式切片。注意返回结果的长度差异起始分片返回的内容最长包含 4 字节起始码和重新拼好的 NAL 头中间分片最短结束分片返回的数据和中间分片一样只是纯负载。真正的重组循环在外部维护一个字节缓冲把同一个时间戳下的所有分片按顺序 append 起来直到遇到 E bit 才组成完整一帧。2.3 AAC 在 RTP 里的载荷格式AU Header 决定原始数据的读取方式AAC 的 RTP 封装和 H264 完全不是一个套路。AAC 用的是 “AU Header AU Data” 的结构一个 RTP 包可以携带多个 Audio Access Unit。AU Header 的前 4 字节是 16 位的 AU-size 和 16 位的 AU-IndexAU-size 表示单个 AAC 原始帧的字节长度。每一帧 AAC 裸数据前面没有 ADTS 头必须靠 AU Header 里记录的 size 来切分。def parse_aac_rtp(payload): # 前 4 字节是 AU Headers 的长度以字节为单位 headers_length int.from_bytes(payload[0:2], big) # 每个 AU Header 占 4 字节新版或 2 字节旧版 offset 2 frames [] pos 2 headers_length while offset 2 headers_length: au_size int.from_bytes(payload[offset:offset2], big) # AU Header 里 size 的计数单位是“字”一个字等于 8 字节 au_size au_size 3 frames.append(payload[pos:posau_size]) pos au_size offset 4 return framesAU Header 里 AU-size 的计数单位极容易搞错。规范规定这个 16 位字段表示的是 “AU size in words”一个字等于 8 字节。如果你不右移三位直接用切出来的 AAC 帧长度全部是实际长度的 8 倍合并出来的音频直接是噪音。这是所有 RTP 转 MP4 工具最容易翻车的地方之一比 H264 重组隐蔽得多因为标签不起作用只有切片完解码才知道对不对。3. 时间戳与 StrideRTP 的时间线如何无损映射到 MP4 的 sample 时间轴3.1 90kHz 的视频时间戳和 44.1kHz 的音频时间戳两个不同刻度的钟RTP 时间戳的单位跟编码格式相关。H264 的 RTP 时间戳以 90kHz 为基准每 tick 是 1/90000 秒AAC 的 RTP 时间戳以采样率为基准44.1kHz 音频每 tick 是 1/44100 秒48kHz 音频则是 1/48000 秒。合成 MP4 时必须把两套时间线统一换算成 MP4 容器的 timescale通常视频用 90000音频用采样率或统一用 90000。假设一个视频帧的时间戳是 rtp_ts_video 3600 的 RTP 包对应播放时刻是 3600/90000 0.04 秒一个音频帧时间戳 rtp_ts_audio 8820对应 8820/44100 0.2 秒。要转成 MP4 的 90000 timescale音频帧的 MP4 时间戳 8820 * 90000 / 44100 18000。换算公式的核心是比例关系不是简单的加减。3.2 时间戳差值映射为什么第一个 video sample 的时间戳不强制归零很多开发者会犯的第一个错误是“把首帧时间戳设为 0”。RTP 流的第一个视频包时间戳往往不是从 0 开始的有的设备从 1432 这种随机偏移开始。MP4 的 sample 时间轴要求从 0 开始但不代表你要把 timestamp 减去第一个包的 RTP 时间戳。正确做法是记录首帧 RTP 时间戳 rst_offset然后所有帧的 MP4 时间戳 (rtp_ts - rst_offset) * mp4_timescale / rtp_rate。这样第一个 sample 的 pts 确实是 0但整个时间线的相对间隔保持不变。如果直接拿 rtp_ts 去算文件时间轴会带着一个巨大的偏移播放器显示的总时长和音画错位全都乱。def rtp_clock_to_mp4_ts(rtp_ts, rtp_rate, start_offset, mp4_timescale): # 核心比例换算先算相对包的 tick 偏移再换算到目标 timescale relative_ticks rtp_ts - start_offset return int(relative_ticks * mp4_timescale / rtp_rate)3.3 B 帧陷阱MP4 的 PTS 和 DTS 是两套字段compose offset 不能省H264 视频流如果用了 B 帧解码顺序和显示顺序不一致。MP4 的 sttstime-to-sample表存的是 DTS 间隔另有一个 stss 表标记同步帧还有一个 cttscomposition time offset表记录每个 sample 的 PTS 与 DTS 差值。RTP 抓包层面你看不到 PTS因为在 RTP 传输中通常只携带一个时间戳它代表的是解码顺序DTS 对应的表现。如果你把 RTP 时间戳直接当作 MP4 的 PTS 写入 sttsB 帧场景下画面会乱跳。很多开源工具简单粗暴地把 RTP 时间戳当作 DTS然后 PTS 等于 DTS这在全部是 IDR 和 P 帧的流里没问题一旦出现 B 帧就翻车。MP4 规范允许 ctts 表为空这意味着 PTS DTS但如果实际有 B 帧播放器会按错误的顺序显示。作为合成工具你要么放弃对 B 帧流的支持并明确报错要么在解包时借用一些启发式规则估算 B 帧的 display order——通常的做法是用 SPS 里的 max_num_reorder_frames 和帧间 POC 差值推算每帧的 offset。4. MP4 容器内部mdat 写数据、moov 写索引两个 Box 的协作4.1 Box 结构全景ftyp、moov、mdat、moof 到底谁管什么MP4 是 ISO BMFF 的 Apple 扩展版基本构建单元是 Box也叫 atom。每个 Box 开头是 4 字节长度和 4 字节类型。一个最小可播放的 MP4 至少要有 ftyp文件类型、moov元数据、mdat媒体数据三种 Box。ftyp 声明兼容性moov 里嵌套 trak轨道、stblsample tablemdat 里是按 Track 顺序交错排列的 sample 原始数据。RTP 流合成 MP4 时通常做法是先写 ftypmdat 边接收边写数据但 moov 必须到最后才写因为 sample 的字节偏移stco 表依赖 mdat 里数据的最终布局。如果你的工具支持 moov 前置faststart那就要先写一个空的 moov 占位算好所有 sample 的偏移后回来覆盖。这里牵扯到一个经典问题网络流是分批到的提前预留的 moov 空间不够就会 Box 错位播放器直接报 “moov atom not found”。4.2 Sample Table 的五个核心表stts、stsz、stsc、stco、cttsMP4 的样本数据是“按 chunk 组织”的。chunk 是一组连续存储的 samplestsc 表告诉播放器“每个 chunk 里有多少个 sample、从第几个 sample 开始变化”stco 表告诉播放器“每个 chunk 的字节偏移在哪”。stts 存每个 sample 的 DTS 递增间隔stsz 存每个 sample 的大小ctts 存 PTS 与 DTS 的偏移。构建 sample table 常见实现思路是先收集所有 sample 的字节大小计算出每个 chunk 的偏移最后一次性生成这几个表。视频 track 建议每个 chunk 放 1 个 sample即每帧一个 chunk音频 track 每个 chunk 放 1024 个 sample即每 1024 个音频帧一块。原因很朴素chunk 越大stco 表项越少文件越小但太大会导致随机访问时需要加载大量无关数据。视频用 1因为关键帧之后的帧解码依赖前一帧随机访问还是要落在关键帧音频用 1024因为 AAC 解码天然按块为单位。typedef struct { uint32_t sample_count; uint32_t sample_delta; } stts_entry; typedef struct { uint32_t sample_count; uint32_t sample_size; } stsz_entry;4.3 SPS/PPS 注入MP4 的 avcC Box 与 RTP 里的 STAP-A 聚合包H264 在 RTP 流中以 STAP-A 聚合包出现的 SPS 和 PPS不能直接丢弃。MP4 容器需要把这两个参数集写进 avcC Box并且每个关键帧 sample 前要加 4 字节 NAL 长度前缀Length-Prefixed NAL而不是 AnnexB 风格的 00 00 00 01 起始码。转换时要把从 RTP 里恢复出来的 SPS/PPS 二进制写入 avcC Box 的 sequenceParameterSet 字段再把视频 sample 里的 AnnexB 起始码替换为 4 字节大端长度。这一步常有三种错误SPS/PPS 漏写导致解码器无法初始化起始码没替换导致 MP4 里的 NAL 长度信息错乱SPS/PPS 在 RTP 流中只出现一次如果起始的包丢了你需要从关键帧的 FU-A 里找。成熟的工具会维护一个全局的 SPS/PPS 缓存在收到任何 STAP-A 包时更新写 moov 时统一取出。// Length-Prefixed NAL 的写入方式每个 NAL 前 4 字节为 length uint32_t nalu_len sample_nal_size; uint8_t prefix[4] { (uint8_t)(nalu_len 24), (uint8_t)(nalu_len 16), (uint8_t)(nalu_len 8), (uint8_t)(nalu_len) };如果工具不管 SPS/PPS 缓存直接拿第一个关键帧的 avcC 写进去遇到“首包不是关键帧”或者“SPS 单独丢包重传”的场景生成的 MP4 就是废文件。写文件前检查缓存是否为空的这一步是所有血泪经验堆出来的关键路径。5. RTP 转 MP4 避坑手册五条高频翻车现场的血泪排查5.1 现象生成的 MP4 时长对不上播放器拖进度条直接崩溃原因分析RTP 包在传输途中会因为网络抖动丢包重排音频和视频的 RTP 时间戳是各自独立增长的两轨的 sample 数量和时长不匹配。播放器按 MP4 的 mvhd Box 里声明的总时长读文件如果实际 mdat 数据不足拖到末尾就崩。解决办法合成前先统计两轨的 RTP 时间戳最大值和最小值算出各自的实际时长以较短者为基准截断mvhd 总时长写成两者中的较大值同时把 stts 表填满直到覆盖区间。强制让两轨时长一致比留空洞安全得多。某次直播流转存档音频末端丢了 200ms直接按原样合成后每拖到 58 分钟必闪退截断后问题消失。5.2 现象音画不同步越来越严重开头 10 秒正常3 分钟差 2 秒原因分析典型的时间戳换算率错误。音频 RTP 时钟是采样率44100视频是 90000如果换算时用错了 rtp_rate比如把音频按 48000 换算进 44100 的流每秒钟音频会多 3900 ticks误差持续累积。解决办法换算前先读取 SDP 或抓包信息里的媒体描述确认采样率不能按 44100 硬编码。换算环节加一个自检用音频总 tick 数和视频总 tick 数分别除以各自 rtp_rate得到的秒数差异超过 50ms 就报警。这招救了 A 同学一个凌晨上线的项目他推演了三个小时的“玄学”偏移最后发现是采样率写死。5.3 现象视频播放正常音频全是爆音或完全无声原因分析AAC 的 RTP 载荷切分错误最典型的就是 5.2 里说的 AU-size 的字长陷阱。另一个原因是从 RTP 提取的 AAC 裸流没有配对 ADTS 头信息。MP4 的音频 sample 不需要 ADTS 头用 esds Box 里的 AudioSpecificConfig 描述编码配置但如果你用抓包工具导出的数据是带 ADTS 的直接塞进 mdat 会多出 7 字节的偏移解码器按 ASC 配置读数据时全部错位。解决办法在解析 AAC 载荷时明确区分 ADTS 和 RAW。如果源流是 ADTS先 strip 掉每帧前 7 字节再进 mdat同时用 AudioSpecificConfig 里的 samplingFrequencyIndex 和 channelConfiguration 生成 esds 描述而不是抄 ADTS 头。把“是否剥 ADTS 头”做成一个输出开关比判断系统写死更实用。5.4 现象视频花屏播放器报 “cannot decode” 但文件大小看起来正常原因分析FU-A 重组时丢包导致 NAL 不完整最常见的是拿一个不完整的关键帧 NAL 去组装后续帧。关键帧的 NAL 碎片一旦有缺失整个 GOP 的解码都受影响而 P 帧不完整只影响当前帧不会传到后面。解决办法重组时维护当前 NAL 的帧类型。收到 S bit 时记录这一帧的 NAL type如果 type 是 IDRnal_unit_type 5且后续分片有任何缺失整个 GOP 直接标记为损坏并丢弃直到下一个关键帧重来。这个“保关键帧”策略在很多商业转码工具里都能看到属于宁可丢画面不能花屏的工程取舍。某开源播放器对花屏的容忍度低故意拿有瑕疵的关键帧合成 MP4播放器直接罢工丢弃整个 GOP 后文件能正常跳转播放。5.5 现象文件拍平后一切正常一旦 seek跳转到中间位置就黑屏原因分析MP4 没有约定好关键帧间隔。RTP 流里关键帧出现频率不固定或者合成时 stss 表sync sample table漏标 IDR 帧。播放器 seek 时希望把播放位置跳到最近的关键帧如果 stss 表是空的或只有开头一个 IDR就会从头解码到目标位置体验是黑屏转圈。解决办法合成 stss 表时把每个 IDR sample 的序号写进去。同时建议在 RTP 侧按视频编码的 GOP 结构判断关键帧的出现规律如果超过 10 秒都没有 IDR 帧要么标记文件不支持 seek要么做进程内间隔注入有风险因为编码器可能不接受非关键帧刷新至少要在合成日志里提示用户“该流关键帧间隔过长”。不写 stss 表生成的 MP4 在很多软播放器里能播但在硬件播放器和剪辑软件里翻车是必然的。这就是 “stss 不能省” 的原因哪怕全文件只有一个关键帧也要写。6. 为什么是 MP4 而不是 TS设计取舍里藏着的工程答案写合成工具时至少要被问一次RTP 流直接转成 TSMPEG-2 Transport Stream不是更简单吗TS 的包结构固定 188 字节时间戳用 PTS/DTS 双字段RTP 的负载可以直接塞进 TS 的 payload 区几乎没有时间轴换算的压力而且 TS 天生抗丢包网络推流都用它。抛开兼容性来看TS 才是更自然的“RTP 数据收纳盒”。但 MP4 之所以是大众播放器、编辑软件、短视频平台统一接受的格式核心原因是随机访问能力。TS 的索引信息分散在每个包的小字段里播放器要从头扫到目标位置才能定位MP4 的 moov 索引是集中式的seek 到任意时间点的复杂度是 O(1)。这个取舍在短文件上感觉不到但一个 4K 长视频TS 的 seek 时间会膨胀到不可接受。这就引出一个更深层的问题合成工具应该提供 “流模式” 和 “文件模式” 两套输出。流模式走 TS保证实时性和抗丢失文件模式走 MP4保证可编辑、可跳转、可上传。很多项目一开始直接封装 MP4后来发现推流场景下 RTP 丢包导致 MP4 接收端卡死才补了 TS 输出。这是典型的设计取舍教训容器选择是业务需求驱动的不是技术品味驱动的。我自己现在接这类任务第一步不是写解析逻辑而是先问一句“合出来的文件要拿来干什么”。能忍受从头播到末尾就选 TS要求剪辑软件能直接拖时间线就强制 MP4 并必须写好 stss 和 ctts。从那以后我每次收到 RTP 合成需求都强制先在解析流程前面加一段“输出用途确认”把两轨时长对齐检查和 SPS 缓存检查放在主流程的入口——这些小习惯让我少捡了无数次坑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑