
简介本资源是一套基于RTP实时传输协议的音视频流封装工具面向多媒体开发工程师、音视频协议学习者及嵌入式流媒体项目实践者解决H.264视频与AAC音频RTP流实时接收后合成标准MP4文件的核心问题适用于网络监控回放、会议录制、SDP协商调试等典型场景。压缩包共31个文件体量精简仅197KB包含2个核心C源码main.c、rtp.c与对应头文件17个.h、1个Makefile构建脚本、1个SDP会话描述文件、1个可执行工具cmmb2rtp、1个测试MP4样例及配套XML工程配置文件整体结构体现轻量级协议解析→数据重组→MP4muxing的完整链路。已有486人学习下载读者可直接复用其RTP包解析逻辑、H.264/AAC时间戳对齐策略、MP4v2库调用范式并通过test.mp4验证输出效果是理解流媒体封装底层机制的高实用性参考实现。1. 为什么用 RTP 包直接合成 MP4 总是卡在“文件能打开但没画面”——H264AAC 的 RTP 流到 MP4 转封装不是解码重编码而是时间戳对齐、NALU 拆包、ADTS 封装与容器语义重建的精密缝合你手头有一段从某嵌入式设备、IPC 摄像头或自研流媒体网关发出来的 RTP 流视频是 H.264AVC编码音频是 AAC-LC走的是标准 RTP/UDP 协议栈每个包带 RTP header、可能含 FU-A 分片、SPS/PPS 内联或独立发送音频包含 ADTS 头或原始 AAC 帧。你想把它存成一个可被 VLC、ffplay、iOS 系统相册、微信转发、甚至上传到短视频平台直接预览的 MP4 文件——但试过ffmpeg -i rtp://... -c copy out.mp4却失败要么报错Invalid data found when processing input要么生成的 MP4 文件体积极小、播放器提示“无有效轨道”或者能拖动进度条但始终黑屏无声。这不是 FFmpeg 不行而是 RTP 流本身不携带 MP4 所需的时间基timebase、轨道结构track layout、moov 元数据、mdat 数据块对齐、CTTS/BTRT 时间修正表。真正可靠的合成路径是把 RTP 流看作“裸数据源”自己完成① RTP 包解析与去抖动缓冲 → ② H.264 NALU 提取与关键帧对齐 → ③ AAC 帧提取与 ADTS 补全 → ④ 两路流的时间戳重映射PTS/DTS 对齐到统一 timescale→ ⑤ 构建 ISO BMFFMP4二进制结构ftyp moov含 avc1/mp4a 描述、stsd/stts/stss/stsc/stco/co64 mdat。这不是“转格式”而是“从零组装容器”。适合正在调试 IPC 接入、做边缘录像回溯、开发私有流媒体归档模块、或需要离线分析 RTP 抓包文件如.pcap的嵌入式/音视频工程师。本文不依赖 GStreamer 黑盒 pipeline不调用 libavdevice 的 rtp demuxer它默认不支持自定义 RTP 时间戳映射全程用 Python av/moviepy辅助验证核心逻辑用纯 Python 实现所有字节操作可 debug、可断点、可注入日志——因为你在生产环境里最怕的从来不是写不出代码而是出问题时连哪一行字节错了都看不到。2. 从 RTP 抓包文件.pcap或实时 UDP 流中提取原始 H.264 和 AAC 帧解析 RTP header、识别 FU-A 分片、恢复完整 NALU、补全 AAC ADTS 头RTP 流不是“一包一帧”尤其对 H.264一个关键帧IDR可能大到 100KB远超 UDP 单包 1500 字节上限必须分片传输。RFC 3984 定义了三种分片模式Single NAL Unit单 NALU 一包、STAP-A聚合包、FU-A分片单元。实际设备中FU-A 最常见。而 AAC 音频在 RTP 中通常以 “raw AAC” 方式传输即无 ADTS 头靠接收端根据 SDP 中的profile-level-id和sprop-aac参数自行补全。这两点是后续合成 MP4 的前置生死线——漏一帧 FU-AH.264 解码器就卡死少一个 ADTS 头AAC 解码器直接拒绝初始化。2.1 解析 RTP 包结构并分类处理识别 payload type、sequence number、timestamp、marker bit 与 payload offsetRTP header 固定 12 字节但 payload 起始位置受 CSRC 计数影响。我们用struct.unpack精确读取import struct def parse_rtp_header(data: bytes) - dict: if len(data) 12: return None # 0-1: version(2)pad(1)ext(1)csrc_count(4), payload_type(7), marker(1) # 2-3: sequence number (big-endian) # 4-7: timestamp (big-endian) # 8-11: ssrc (big-endian) v_p_x_cc, pt_m, seq, ts, ssrc struct.unpack(!BBHII, data[:12]) version (v_p_x_cc 6) 0x03 padding (v_p_x_cc 5) 0x01 extension (v_p_x_cc 4) 0x01 csrc_count v_p_x_cc 0x0F payload_type pt_m 0x7F marker (pt_m 7) 0x01 payload_offset 12 4 * csrc_count if extension: # skip extension header (2 2 N*4) if len(data) payload_offset 4: ext_len struct.unpack(!H, data[payload_offset2:payload_offset4])[0] payload_offset 4 4 * ext_len return { version: version, padding: padding, extension: extension, csrc_count: csrc_count, payload_type: payload_type, marker: marker, sequence: seq, timestamp: ts, ssrc: ssrc, payload_offset: payload_offset, payload: data[payload_offset:] if len(data) payload_offset else b }注意payload_offset必须动态计算。很多开源工具硬写12导致 FU-A 分片解析错位——因为加了 RTP extension 后payload 就不在第 12 字节了。markerbit 是关键对视频它标示该 RTP 包是某一帧的最后一片FU-A 的 last fragment对音频它常标示语音活动结束VAD但在 AAC 场景下我们更关注它是否与 SDP 中声明的packetization-mode1匹配。2.2 H.264 FU-A 分片重组按 NALU 类型、start/end 标志拼接完整帧FU-A 分片的 payload 第一字节是fu_indicator第二字节是fu_header。fu_indicator 0xE0是 NALU header 的前 3 位forbidden_zero_bit, nal_ref_idc, nal_unit_typefu_header 0x80是 start bitfu_header 0x40是 end bitfu_header 0x1F是真正的 nal_unit_type。def parse_fu_a(payload: bytes) - tuple[int, bool, bool, bytes]: if len(payload) 2: return 0, False, False, b fu_indicator payload[0] fu_header payload[1] nal_unit_type fu_header 0x1F start_bit bool(fu_header 0x80) end_bit bool(fu_header 0x40) # FU-A payload starts from byte 2, and first two bytes are replaced by original NALU header original_nalu_header bytes([(fu_indicator 0xE0) | nal_unit_type]) nalu_payload payload[2:] return nal_unit_type, start_bit, end_bit, original_nalu_header nalu_payload # 重组逻辑简化版实际需按 timestampsequence 缓存 def reassemble_h264_fragments(packets: list[dict]) - list[tuple[int, bytes]]: fragments {} complete_frames [] for pkt in packets: if pkt[payload_type] not in (96, 100): # 常见 H264 PT continue payload pkt[payload] if len(payload) 2: continue # 判断是否 FU-A: RFC3984 规定 FU-A 的 nal_unit_type 在 FU indicator 中为 28 if payload[0] 0x1F 28: nal_type, start, end, nalu_part parse_fu_a(payload) key (pkt[timestamp], nal_type) # 同一帧 timestamp 相同nal_type 一致 if key not in fragments: fragments[key] {parts: [], start: False, end: False} fragments[key][parts].append(nalu_part) fragments[key][start] | start fragments[key][end] | end if fragments[key][start] and fragments[key][end]: # 拼接所有 parts按 sequence order 更严谨此处简化 full_nalu b.join(sorted(fragments[key][parts], keylambda x: len(x))) # 实际应按 sequence 排序 complete_frames.append((nal_type, full_nalu)) del fragments[key] return complete_frames逻辑说明reassemble_h264_fragments是简化示意。真实场景必须用(ssrc, timestamp)作为 key并维护一个滑动窗口缓存最近 100 个 packet按sequence number排序后合并——因为网络乱序会导致 start 包晚于中间包到达。full_nalu开头必须是合法 NALU header00 00 00 01 或 00 00 01否则 MP4 muxer 会拒收。SPS/PPSnal_unit_type7/8必须在 IDR 帧nal_unit_type5之前写入 MP4 的avcCbox这是硬性要求。2.3 AAC 帧提取与 ADTS 头补全从 raw AAC 到可 mux 的 ADTS 帧RTP 中的 AAC 通常不带 ADTS 头只传 raw data。SDP 中会声明afmtp:97 profile-level-id1;modeAAC-hbr;sizelength13;indexlength3;indexdeltalength3;config1210其中config1210是 base64 编码的 AudioSpecificConfigASC需解码为 2 字节 binary再按 MPEG-4 Part 3 Annex A 解析采样率、声道数等。但更常用且鲁棒的做法是直接从 SDP 的config参数提取 ASC并构造固定 ADTS 头。import base64 def build_adts_header(aac_config: bytes, frame_length: int) - bytes: aac_config: 2-byte binary from SDP configxxxx frame_length: total length of ADTS frame (header raw AAC) # ADTS header is 7 bytes # syncword: 0xFFF (12 bits) # ID: 0 (MPEG-4), layer: 0, protection_absent: 1 # profile: (aac_config[0] 3) 0x03 # sampling_frequency_index: lookup table based on aac_config # private_bit: 0, channel_configuration: (aac_config[0] 0x01) 2 | (aac_config[1] 6) # original_copy: 0, home: 0 # copyright_identification_bit: 0, copyright_identification_start: 0 # frame_length: 14 bits (this field includes ADTS header) # adts_buffer_fullness: 0x7FF (variable bitrate) # number_of_raw_data_blocks_in_frame: 0 profile (aac_config[0] 3) 0x03 sampling_freq_idx _get_sampling_freq_idx(aac_config) # 查表函数略 channel_conf ((aac_config[0] 0x01) 2) | ((aac_config[1] 6) 0x03) # Build header: 121211111113112 48 bits 6 bytes? No, its 7 bytes. # Standard ADTS header layout (7 bytes): # [12][1][2][1][1][1][1][1][1][1][13][11][2] - actually packed into 7 bytes # We use fixed values for simplicity: profile1, sampling44.1kHz(idx4), channels2 # Full 7-byte header: header bytearray(7) header[0] 0xFF header[1] 0xF1 # MPEG-4, Layer 0, no CRC header[2] (profile 6) | (sampling_freq_idx 2) | (channel_conf 2) header[3] ((channel_conf 0x03) 6) | ((frame_length 11) 0x03) header[4] (frame_length 3) 0xFF header[5] ((frame_length 0x07) 5) | 0x1F header[6] 0xFC return bytes(header) def _get_sampling_freq_idx(config: bytes) - int: # Simplified: assume config0x1210 means 44.1kHz - idx4 # Real impl reads from config bits return 4参数说明frame_length是 ADTS 帧总长7 字节 header raw AAC data 长度必须精确。MP4 muxer 对 AAC 帧长度极其敏感——多 1 字节或少 1 字节整个 audio track 就无法 decode。aac_config必须从 SDP 解析不能硬编码。若 SDP 未提供config则无法安全补 ADTS此时应丢弃该音频流或告警。3. 构建 MP4 容器手动编写 ftyp/moov/mdat 结构设置 AVC1 和 MP4A 轨道参数填入时间戳映射表FFmpeg 的-c copy失败根本原因是它期望输入流已包含完整、合规的 container metadata。而 RTP 是传输层协议不提供任何容器语义。因此我们必须自己构建 ISO Base Media File FormatISO/IEC 14496-12的二进制结构。这不是“调库写文件”而是理解moov中trak→mdia→minf→stbl→stsd/stts/stss/stsc/stco各 box 的字节布局与语义约束。核心挑战有三① H.264 的avcCbox 必须包含 SPS/PPS 的 exact binary② AAC 的esdsbox 必须正确描述 AudioSpecificConfig③sttsdecoding time to sample和cttscomposition time to sample必须将 RTP timestamp 映射到 MP4 timescale通常为 90kHz 或 44.1kHz。3.1 构造 ftyp box 和 moov box 框架定义 major_brand、minor_version、track 数量与 timescaleMP4 文件以ftyp开头声明兼容品牌如isom,mp41,avc1。moov是元数据根 box必须在文件开头或用freebox 占位后追加但首写最稳。def write_ftyp(fp): # ftyp size20, major_brandisom, minor_version512, compatible_brands[isom,iso2,avc1,mp41] fp.write(b\x00\x00\x00\x14) # size20 fp.write(bftyp) fp.write(bisom) fp.write(b\x00\x00\x02\x00) # minor_version512 fp.write(bisom) fp.write(biso2) fp.write(bavc1) fp.write(bmp41) def write_moov_header(fp, video_timescale90000, audio_timescale44100): # moov size placeholder (will patch later) moov_start fp.tell() fp.write(b\x00\x00\x00\x00) # placeholder for size fp.write(bmoov) # mvhd: movie header fp.write(b\x00\x00\x00\x6c) # size108 fp.write(bmvhd) fp.write(b\x00) # version0 fp.write(b\x00\x00\x00\x00) # flags # creation/modification time: use epoch fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) fp.write(struct.pack(I, video_timescale)) # timescale fp.write(struct.pack(I, 0)) # duration (to be patched) fp.write(b\x00\x01\x00\x00) # rate 1.0 fp.write(b\x01\x00) # volume 1.0 fp.write(b\x00 * 10) # reserved matrix fp.write(b\x00 * 24) # pre_defined, next_track_ID # trak boxes will follow... return moov_start逻辑说明write_moov_header只写框架。mvhd中的timescale是全局时间刻度视频轨用 90kHz匹配 RTP H.264 timestamp音频轨用 44.1kHz匹配 AAC 采样率。duration留空最后遍历所有帧后回填。moov必须在mdat之前且mdat必须紧贴moov后——某些播放器如 iOS AVPlayer对 box 顺序敏感。3.2 写入视频轨trakavc1 格式、SPS/PPS 写入 avcC、stsd/stts/stss/stsc/stco 全链路填充H.264 视频轨的stsdbox 中必须包含avc1子 box并在其avcCbox 中写入 SPS 和 PPS 的原始字节不含起始码 00 00 00 01只写 NALU payload。stts记录每帧的持续时间DTS deltastss标记关键帧IDR索引stsc/stco关联 chunk 与 sample 位置。def write_video_trak(fp, sps: bytes, pps: bytes, samples: list[dict], timescale90000): trak_start fp.tell() # trak size placeholder fp.write(b\x00\x00\x00\x00) fp.write(btrak) # tkhd fp.write(b\x00\x00\x00\x5c) # size92 fp.write(btkhd) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) # creation/mod time fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) fp.write(struct.pack(I, 1)) # track_ID 1 fp.write(b\x00\x00\x00\x00) # duration (patch later) fp.write(b\x00 * 64) # layer, alternate_group, volume, matrix, width, height # mdia fp.write(b\x00\x00\x00\x00) # mdia size placeholder fp.write(bmdia) # mdhd fp.write(b\x00\x00\x00\x20) # size32 fp.write(bmdhd) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) # creation/mod time fp.write(struct.pack(I, timescale)) # timescale fp.write(struct.pack(I, 0)) # duration (patch) fp.write(b\x00\x00) # language und fp.write(b\x00\x00) # quality 0 # hdlr fp.write(b\x00\x00\x00\x15) # size21 fp.write(bhdlr) fp.write(b\x00\x00\x00\x00) # version/flags fp.write(bvide) # handler type fp.write(b\x00 * 12) # name (null-terminated) # minf fp.write(b\x00\x00\x00\x00) # minf size placeholder fp.write(bminf) # vmhd fp.write(b\x00\x00\x00\x14) # size20 fp.write(bvmhd) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(b\x00\x00\x00\x00) # graphicsmode fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) # opcolor # dinf fp.write(b\x00\x00\x00\x1c) # size28 fp.write(bdinf) fp.write(b\x00\x00\x00\x14) # dref size20 fp.write(bdref) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(b\x00\x00\x00\x01) # entry_count fp.write(b\x00\x00\x00\x0c) # url size12 fp.write(burl ) fp.write(b\x00\x00\x00\x01) # flags1 (self-contained) # stbl fp.write(b\x00\x00\x00\x00) # stbl size placeholder fp.write(bstbl) # stsd stsd_start fp.tell() fp.write(b\x00\x00\x00\x00) # stsd size placeholder fp.write(bstsd) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(b\x00\x00\x00\x01) # entry_count1 # avc1 fp.write(bavc1) # data_format fp.write(b\x00 * 6) # reserved fp.write(b\x00\x00) # data_reference_index fp.write(b\x00\x00\x00\x00) # pre_defined fp.write(b\x00\x00\x00\x00) # pre_defined fp.write(b\x00\x00\x00\x00) # pre_defined fp.write(b\x00\x00\x00\x00) # pre_defined fp.write(b\x00\x00\x00\x00) # pre_defined fp.write(b\x00\x00\x00\x00) # pre_defined fp.write(b\x00\x00) # width fp.write(b\x00\x00) # height fp.write(b\x00\x00\x00\x00) # horiz_resolution fp.write(b\x00\x00\x00\x00) # vert_resolution fp.write(b\x00\x00\x00\x00) # reserved fp.write(b\x00\x00\x00\x00) # reserved fp.write(b\x00\x00\x00\x01) # frame_count1 fp.write(b\x00 * 32) # compressorname (32 bytes) fp.write(b\x00\x00\x00\x00) # depth fp.write(b\x00\x00\x00\x00) # pre_defined # avcC avcc_start fp.tell() # avcC size placeholder fp.write(b\x00\x00\x00\x00) fp.write(bavcC) fp.write(b\x01) # configurationVersion fp.write(sps[1:4]) # AVCProfileIndication, profile_compatibility, AVCLevelIndication fp.write(b\xff) # lengthSizeMinusOne 3 (means 4 bytes NALU length) fp.write(b\xe0) # numOfSequenceParameterSets 1 (low 5 bits) # SPS length and data fp.write(struct.pack(H, len(sps))) fp.write(sps) fp.write(b\x01) # numOfPictureParameterSets 1 fp.write(struct.pack(H, len(pps))) fp.write(pps) # patch avcC size avcc_size fp.tell() - avcc_start fp.seek(avcc_start) fp.write(struct.pack(I, avcc_size)) fp.seek(0, 2) # back to end # patch stsd size stsd_size fp.tell() - stsd_start fp.seek(stsd_start) fp.write(struct.pack(I, stsd_size)) fp.seek(0, 2) # stts: decoding time to sample stts_start fp.tell() fp.write(b\x00\x00\x00\x00) # stts size placeholder fp.write(bstts) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(struct.pack(I, len(samples))) # entry_count prev_ts samples[0][dts] for s in samples: delta s[dts] - prev_ts if s ! samples[0] else 0 fp.write(struct.pack(I, 1)) # sample_count fp.write(struct.pack(I, delta)) prev_ts s[dts] stts_size fp.tell() - stts_start fp.seek(stts_start) fp.write(struct.pack(I, stts_size)) fp.seek(0, 2) # stss: sync sample (key frames) keyframes [i for i, s in enumerate(samples) if s.get(is_keyframe, False)] if keyframes: stss_start fp.tell() fp.write(b\x00\x00\x00\x00) # stss size placeholder fp.write(bstss) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(struct.pack(I, len(keyframes))) # entry_count for idx in keyframes: fp.write(struct.pack(I, idx 1)) # 1-indexed stss_size fp.tell() - stss_start fp.seek(stss_start) fp.write(struct.pack(I, stss_size)) fp.seek(0, 2) # stsc/stco: sample-to-chunk chunk-offset # assume one chunk per sample for simplicity stsc_start fp.tell() fp.write(b\x00\x00\x00\x00) # stsc size placeholder fp.write(bstsc) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(struct.pack(I, 1)) # entry_count fp.write(struct.pack(I, 1)) # first_chunk fp.write(struct.pack(I, 1)) # samples_per_chunk fp.write(struct.pack(I, 1)) # sample_description_index stsc_size fp.tell() - stsc_start fp.seek(stsc_start) fp.write(struct.pack(I, stsc_size)) fp.seek(0, 2) stco_start fp.tell() fp.write(b\x00\x00\x00\x00) # stco size placeholder fp.write(bstco) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(struct.pack(I, len(samples))) # entry_count for _ in samples: fp.write(b\x00\x00\x00\x00) # placeholder, will patch after mdat written stco_size fp.tell() - stco_start fp.seek(stco_start) fp.write(struct.pack(I, stco_size)) fp.seek(0, 2) # patch stbl size stbl_size fp.tell() - (trak_start 8) # trak header is 8 bytes fp.seek(trak_start 4) fp.write(struct.pack(I, stbl_size)) fp.seek(0, 2) # patch minf size minf_size fp.tell() - (trak_start 20) # mdia header is 20 bytes fp.seek(trak_start 20) fp.write(struct.pack(I, minf_size)) fp.seek(0, 2) # patch mdia size mdia_size fp.tell() - (trak_start 12) # tkhd is 12 bytes fp.seek(trak_start 12) fp.write(struct.pack(I, mdia_size)) fp.seek(0, 2) # patch trak size trak_size fp.tell() - trak_start fp.seek(trak_start) fp.write(struct.pack(I, trak_size)) fp.seek(0, 2)参数说明此函数是高度简化的骨架。真实项目中stcochunk offset必须在mdat写完后回溯记录每个 sample 在文件中的绝对偏移并打补丁stts的 delta 必须是 DTS 差值而非 RTP timestamp 差值——因为 RTP timestamp 是单调递增但非均匀的受编码 GOP 影响而 MP4 要求 DTS 严格递增且 delta 合理如 I-frame 为 3000P-frame 为 3000。avcC中的sps[1:4]是 Profile/Level 字段必须从 SPS NALU 中解析不能硬写。width/height也需从 SPS 解析。3.3 写入音频轨trakMP4A 格式、esds 描述 AAC、stts/stsc/stco 适配音频采样率AAC 音频轨使用mp4a格式其esdsbox 必须包含完整的 AudioSpecificConfigASC且stts的 timescale 必须与音频采样率一致如 44100Hz。def write_audio_trak(fp, asc: bytes, samples: list[dict], timescale44100): trak_start fp.tell() fp.write(b\x00\x00\x00\x00) # trak size placeholder fp.write(btrak) # tkhd fp.write(b\x00\x00\x00\x5c) # size92 fp.write(btkhd) fp.write(b\x00) # version fp.write(b\x00\x00\x00\x00) # flags fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) # creation/mod time fp.write(b\x00\x00\x00\x00\x00\x00\x00\x00) fp.write(struct.pack(I, 2)) # track_ID 2 fp.write(struct.pack(I, 0)) # duration (patch later) fp.write(b\x00 * 64) # layer, etc. # mdia fp.write(b\x00\x00\x00\x00) # mdia size placeholder fp.write(bmdia) # md p a hrefhttps://download.csdn.net/download/yangang1899/10497079 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p