RTP流合成MP4:H264与AAC封装实战 简介这份资源面向音视频开发初学者与流媒体协议实践者聚焦于将RTP承载的H264视频与AAC音频数据重新封装为可播放的MP4文件这一典型工程问题。包内共31个文件约197KB以17个C/C头文件与2个源文件构成核心代码框架辅以Makefile、project.xml等构建配置以及sdp会话描述、cmmb2rtp脚本和一份test.mp4样例覆盖从RTP收包、音视频流解析到MP4封装的完整链路。代码中涉及rtp、isma、mp4v2、track等模块划分便于读者理解流媒体解复用与容器写入的协作方式。目前已有486人学习下载适合希望动手实践RTP解包与MP4合成、并借助样例文件快速验证流程的开发者参考。1. 从裸流到可播放文件RTP 里的 H264 和 AAC 到底怎么落成 MP4抓过包的人都有体会Wireshark 里 RTP 流跑得好好的H264 的 NAL 单元、AAC 的 AU 都在可一旦想把它存成能拖进度条、能丢进播放器的 MP4就发现事情没那么简单。裸流是「时间线上的碎片」MP4 是「带索引的容器」中间隔着时间戳换算、帧边界重组、SPS/PPS 注入、ADTS 头剥离这一整套活。这份资源要解决的正是把 RTP 承载的 H264 视频和 AAC 音频按时间戳对齐后合成一个标准 MP4 文件。它适合做流媒体录制、监控回放、音视频存档的工程师也适合想搞懂「为什么我合成的 MP4 只有第一帧能播」的排错党。下面按「原理立住 → 动手复现 → 坑在哪」的顺序拆开讲。2. 拆开 RTP 载荷H264 的 NAL 与 AAC 的 AU 怎么取出来2.1 先分清 RTP 包里的三种东西RTP 本身只是个传输壳真正要处理的是它载荷里的编码数据。H264 在 RTP 里通常有两种打包方式单一 NAL 单元模式一个 RTP 包就是一个完整 NAL和分片模式FU-A一个 NAL 被拆到多个 RTP 包。AAC 则一般走 mpeg4-generic 载荷一个 RTP 包可能装一个或多个 AAC 的 AUAccess Unit每个 AU 前面可能带 ADTS 头也可能不带——这取决于 SDP 里的config参数。判断依据在 SDP 里。H264 看sprop-parameter-sets它给出 SPS 和 PPS 的 Base64AAC 看config它给出 AudioSpecificConfig。这两个东西是后面写 MP4 的必需材料抓包时如果 SDP 丢了基本就得靠从码流里重新找 SPS/PPS 来救。常见做法是先把 RTP 包按 SSRC 分组再按 sequence number 排序最后按 timestamp 归并成帧。这里有个容易翻车的点RTP 的 timestamp 是采样率下的计数值不是毫秒。H264 的时钟频率通常是 90000AAC 常见是 44100 或 48000。换算成秒要除以对应频率不能混用。2.2 用 Python 把 RTP 载荷还原成 Annex-B 码流下面这段代码演示从 RTP 包列表里提取 H264 的 NAL 单元并拼成 Annex-B 格式带 00 00 00 01 起始码。实际项目里 RTP 包可能来自 pcap 解析或 socket 接收这里用列表模拟。import struct def parse_rtp_header(pkt): # RTP 头固定 12 字节含 CSRC 时更长这里按无扩展处理 b0, b1 pkt[0], pkt[1] version b0 6 padding (b0 5) 1 extension (b0 4) 1 csrc_count b0 0x0F marker (b1 7) 1 payload_type b1 0x7F seq struct.unpack(H, pkt[2:4])[0] timestamp struct.unpack(I, pkt[4:8])[0] ssrc struct.unpack(I, pkt[8:12])[0] header_len 12 csrc_count * 4 if extension: ext_len struct.unpack(H, pkt[header_len2:header_len4])[0] header_len 4 ext_len * 4 return { version: version, marker: marker, pt: payload_type, seq: seq, ts: timestamp, ssrc: ssrc, payload: pkt[header_len:] } def h264_rtp_to_annexb(pkts): # 按 seq 排序FU-A 需要重组 pkts sorted(pkts, keylambda p: parse_rtp_header(p)[seq]) out bytearray() fu_buffer None for p in pkts: h parse_rtp_header(p) if h[pt] ! 96: # 假设 H264 动态载荷类型为 96 continue nal h[payload] nal_type nal[0] 0x1F if nal_type 28: # FU-A 分片 start (nal[1] 7) 1 end (nal[1] 6) 1 orig_type nal[1] 0x1F if start: fu_buffer bytearray() # 重建 NAL 头F 和 NRI 来自 FU indicatortype 用原始类型 fu_buffer.append((nal[0] 0xE0) | orig_type) if fu_buffer is not None: fu_buffer.extend(nal[2:]) if end and fu_buffer is not None: out.extend(b\x00\x00\x00\x01) out.extend(fu_buffer) fu_buffer None elif nal_type in (1, 5, 7, 8): # 单包 NAL out.extend(b\x00\x00\x00\x01) out.extend(nal) return bytes(out)逻辑说明parse_rtp_header负责剥掉 RTP 头注意 CSRC 和扩展头会让头变长很多简易实现只减 12 字节遇到带扩展的流就错位。h264_rtp_to_annexb先按 sequence number 排序再处理 FU-A 分片起始分片重建 NAL 头中间分片只取载荷结束分片收尾。参数上载荷类型 96 是常见动态值实际要以 SDP 里的m行为准起始码用 4 字节00 00 00 01兼容性比 3 字节好。AAC 侧的处理思路类似但更简单如果 SDP 里config存在且 RTP 载荷不带 ADTS 头就直接把每个 AU 按长度拼起来如果带 ADTS 头需要先剥掉 7 字节头再交给 MP4 封装。判断方法看 SDP 的sizelength、indexlength等参数或者直接观察载荷前两字节是不是0xFF 0xF1。3. 时间戳对齐与 MP4 封装从裸流到可拖拽文件3.1 为什么不能直接把裸流塞进 MP4MP4 是 ISO BMFF 容器核心是 moov box 里的 track、sample tablestts、stsc、stsz、stco。每个 sample 对应一帧视频或一个 AAC 帧必须给出解码时间戳DTS、时长和文件偏移。裸流只有 RTP 的 32 位 timestamp而且音视频两条流的时间基准不同直接写会导致播放器要么只播第一帧要么音画不同步。正确做法是先分别把 H264 和 AAC 整理成「帧列表」每帧记录相对时间秒和字节数据再以最早的时间戳为基准做归一化最后用封装库写入 MP4。归一化时要注意 RTP timestamp 会回绕32 位溢出跨回绕点要加偏移量否则时间会突然倒退。3.2 用 PyAV 或 ffmpeg 做封装如果不想手写 MP4 box最稳的路子是借助 ffmpeg 的 libavformat。PyAV 是它的 Python 绑定能直接喂 packet。下面示例把上一步得到的 Annex-B 码流和 AAC 帧写成 MP4。import av from av import Packet def write_mp4(h264_annexb, aac_frames, out_path, fps25): container av.open(out_path, modew) vstream container.add_stream(h264, ratefps) vstream.width 1920 vstream.height 1080 vstream.pix_fmt yuv420p astream container.add_stream(aac, rate44100) astream.layout stereo # 视频按起始码切分 NAL每帧一个 packet # 实际项目里应按 AUD 或时间戳切帧这里简化演示 nals h264_annexb.split(b\x00\x00\x00\x01) for i, nal in enumerate(nals): if not nal: continue pkt Packet(nal, streamvstream) pkt.pts i pkt.dts i pkt.duration 1 container.mux(pkt) # 音频每个 AU 一个 packet for i, au in enumerate(aac_frames): pkt Packet(au, streamastream) pkt.pts i pkt.dts i pkt.duration 1024 # AAC 一帧 1024 采样 container.mux(pkt) container.close()逻辑说明add_stream时视频要指定分辨率和像素格式否则封装出的 moov 缺参数播放器解不了。视频 packet 的 pts/dts 这里用帧序号简化真实场景要按 RTP timestamp 换算成 90kHz 时基再乘系数。音频 duration 固定 1024 是 AAC-LC 的帧长如果采样率是 48000时间基也要相应调整。参数上ratefps影响 stts 里的时长写错会导致播放速度异常。如果环境里没有 PyAV用 ffmpeg 命令行也能救急先把 H264 存成.h264AAC 存成.aac再ffmpeg -i video.h264 -i audio.aac -c copy out.mp4。但这种方式对时间戳的还原依赖 ffmpeg 的探测遇到 RTP 时间戳跳变容易翻车适合快速验证。3.3 关键参数对照表参数H264 常见值AAC 常见值写 MP4 时的作用RTP 时钟频率9000044100 / 48000换算真实时间时间基1/900001/采样率stts 时长单位帧时长1/fps1024/采样率sample duration起始码00 00 00 01无Annex-B 分隔配置数据SPS/PPSAudioSpecificConfigavcC / esds box这张表是排错时的第一参照音画不同步先查时钟频率播放器报「moov 缺失」先查配置数据有没有写进 extradata。4. 避坑与排查合成 MP4 时最容易翻车的五件事4.1 现象MP4 能打开但只有第一帧后面全黑原因H264 的 SPS/PPS 没有写进 avcC box或者写成了 Annex-B 格式。MP4 要求 SPS/PPS 以 AVCDecoderConfigurationRecord 结构存在 extradata 里而不是混在码流中。解决封装前把 SPS/PPS 单独提取出来通过vstream.codec_context.extradata设置或者用 ffmpeg 的-bsf:v h264_mp4toannexb反向确认。PyAV 里可以在 add_stream 后手动赋值 extradata。4.2 现象音画不同步越播越偏原因音视频用了各自的 RTP 起始时间戳做零点没有统一基准。或者 AAC 的采样率写成了 44100实际流是 48000。解决取两条流最早时间戳的较小值作为全局零点所有 pts 减去它。采样率从 SDP 的rtpmap行读别猜。4.3 现象播放到某个时间点突然卡死或时间倒退原因RTP timestamp 32 位回绕。90000Hz 下约 13 小时回绕一次长录制必现。解决检测 timestamp 比上一个值小很多时累加一个 2^32 的偏移量。代码里维护wrap_offset每次回绕加一次。4.4 现象AAC 有杂音或直接解不出原因RTP 载荷带了 ADTS 头但封装时又按裸 AU 处理导致每个帧多出 7 字节垃圾。或者反过来该带 ADTS 的流被剥了头。解决看 SDP 里有没有config参数。有 config 通常表示裸 AU需要自己构造 AudioSpecificConfig没有 config 则载荷可能带 ADTS剥掉 7 字节再处理。4.5 现象ffmpeg 报「non-existing PPS 0 referenced」原因码流中间丢了 SPS/PPS或者 FU-A 重组时 NAL 头重建错误导致 PPS id 对不上。解决在关键帧前强制插入缓存的 SPS/PPS检查 FU-A 重组时(nal[0] 0xE0) | orig_type这一步NRI 位不能丢。5. 进阶技巧用 ffprobe 验证与长录制分片合成完别急着交付先跑一遍验证。ffprobe -v error -show_streams -show_format out.mp4看三个东西视频流的nb_frames是否和预期帧数一致音频流的sample_rate是否匹配format 里的duration是否和录制时长吻合。如果nb_frames是 0 或 N/A说明 sample table 没写对播放器拖进度条会失效。长录制场景比如几小时监控存档建议分片写 MP4每片 10 到 30 分钟。原因是 moov box 在文件末尾一次性写几小时的文件中途崩溃整个文件就废了。分片后每片独立可播代价是片与片之间有几帧重叠。实现上可以在封装层维护一个帧计数达到阈值就 close 当前 container 再开新的文件名带序号。另一个实用技巧是处理 B 帧。如果 H264 流里有 B 帧DTS 和 PTS 不再相等写 MP4 时必须分别计算。判断方法看 SPS 里的profile_idcHigh profile 通常有 B 帧。这时候 pts 按显示顺序排dts 按解码顺序排stts 表要按 dts 递增写。很多简易实现直接令 dtspts遇到 B 帧就花屏。我自己的习惯是每次合成完先 ffprobe 看帧数和时长再用播放器拖到中间、末尾各播 10 秒确认没有花屏和音画漂移最后才归档。这套流程帮我挡掉过好几次「文件能打开但内容不对」的返工。希望帮到你。本文还有配套的精品资源点击获取