ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RTP流合成MP4全指南:H.264分片重组与moov样本表解析

RTP流合成MP4全指南:H.264分片重组与moov样本表解析

简介:基于H.264与AAC的实时传输协议媒体流合成MP4文件的参考工程,面向音视频流媒体开发者和专业学生,解决从实时网络包中提取视频与音频并封装为MP4容器的常见需求,适合有一定RTP和编码基础的学习者。压缩包仅197KB,共31个文件,以17个头文件、2个C语言源文件和构建脚本为主,辅助文件包括会话描述、工程配置、测试输出及命令行脚本,整体目录划分明确,可快速定位到码流解析、音视频映射、封装写入等核心模块,各文件职责清晰,便于从接口声明到实现逻辑逐层理解。目前已有487人学习下载,常用于流媒体入门实验和MP4封装改造场景,对录播、视频监控、直播转码等方向有直接参考价值。通过研读源码可理解H.264与AAC在实时传输协议中的打包规则,包括NAL单元拆包、AAC访问单元封装、时间戳同步及轨道创建等关键流程,同时可借鉴其工程结构来扩展自己的功能。

1. RTP 裸流为什么不能直接当 MP4 用

抓 RTP 流通常是直播录制、监控回放、会议取证里最先搞定的一步:会话建立完,按 SSRC 过滤,把 payload 一坨一坨导出来就行。真正让一个开发者卡住很久的,是这个文件永远不能直接拖进播放器。于是,「基于 H.264、AAC 的 RTP 媒体流数据合成 mp4 文件」就成了一个必须同时懂三套机制的活儿:RTP 如何把一帧拆成分片、MP4 如何用 moov 里的样本表描述每一帧的位置和时长,以及两套时钟域怎么换算成播放器认得的节奏。

直接拼包为什么不行?因为 MP4 不是裸流容器,它是一套「先给索引、再给数据」的格式。播放器打开文件的第一件事是读 moov,拿到 stco/stsz/stts 这些表,才知道去 mdat 的哪个偏移读多少字节、显示多久。你把 RTP payload 顺序拼起来,等于只有数据没有索引,播放器无从下嘴。更隐蔽的是,H.264 在 RTP 里被拆成了 FU-A 分片,AAC 又可能一个包里塞好几个音频帧,拼包之前还得先把「网络帧」还原成「编码帧」。

搞懂这条链路的真正价值,不在于能写一个 MP4 合成器,而在于它把流式处理和容器格式的两套世界观对齐了:先定边界(包边界、帧边界、样本边界),再定索引(时间戳、偏移、尺寸),最后才谈写盘策略。下面按这条思路拆。

2. RTP 解包:从分片还原出完整的 NAL 单元和 AAC 帧

2.1 H.264 的负载格式:单 NAL、STAP-A、FU-A 三种形态

H.264 over RTP 的负载格式规定(RFC 6184)里,RTP payload 的第一个字节就是 NAL header:低 5 位是 nal_unit_type,高 3 位里 NRI 表示这个 NAL 是否重要。而 RTP 负载到底长什么样,取决于 nal_unit_type:

  • 1~23:单 NAL 单元模式,一个包就是一个完整的 NAL,SPS、PPS、IDR 切片、SEI 大多走这条路。
  • 24(STAP-A):聚合模式,一个 RTP 包里塞了好几个完整 NAL,每个 NAL 前用 2 字节声明长度。常见于发送方把 SPS + PPS + IDR 切片打包在一起,保证关键帧自包含。
  • 28(FU-A):分片模式,一个 NAL 被拆到多个 RTP 包里。NAL 大于 MTU 时必然走这条路,比如 1080p 的 IDR 切片经常要拆成十几片。

从这里就看出第一个设计取舍:RTP 的设计者没有只做「一刀切分片」,而是保留了三种粒度。单 NAL 让低延迟场景能直接送解码器,STAP-A 减少小 NAL 的头开销,FU-A 解决 MTU 问题。合成器必须同时处理三种形态,因为同一路流里它们会混着出现。

判定逻辑很简单:nal_type 27 和 29 在单 NAL 模式里不会出现(是 MTSI 等扩展用),看到 28 就是 FU-A,看到 24 就是 STAP-A,其余都按单 NAL 处理。注意 6 是 SEI、7 是 SPS、8 是 PPS、5 是 IDR 切片——这些高价值 NAL 通常不参与分片,因为每个包都小于 MTU,但别假设它一定不参与。

2.2 FU-A 重组:start/end 位加序号缺口检测

FU-A 的头结构是:第一个分片带 FU header 的 start 位,最后一个分片带 end 位,中间分片两者都不置位。重组的关键不是按序拼接,而是正确还原 NAL header——它被拆成了两半:NRI 留在第一个 RTP 负载的首字节,nal_unit_type 被挪进了 FU header 的低 5 位。

class H264Depacketizer: def __init__(self): self.pieces = [] # 当前 FU-A 累积的分片 self.last_seq = None # 上一个 RTP 包序号,用于缺口检测 def push(self, seq, payload): # payload 是去掉 RTP 头之后的负载;seq 是 RTP 头里的 16 位序号 nal_type = payload[0] & 0x1f # 序号差用模 65536 算,回绕后 65535 -> 0 不算丢包 if (self.last_seq is not None and (seq - self.last_seq) & 0xffff != 1): self.pieces.clear() # 中间缺包,当前分片作废 self.last_seq = seq if nal_type == 28: # FU-A fu_header = payload[1] start = (fu_header & 0x80) != 0 end = (fu_header & 0x40) != 0 if start: nri = (payload[0] & 0x60) >> 5 inner_type = fu_header & 0x1f # 重组后的 NAL header:NRI 来自首包,type 来自 FU header self.pieces = [bytes([(nri << 5) | inner_type])] self.pieces.append(payload[2:]) else: self.pieces.append(payload[2:]) if end and self.pieces: nal = b''.join(self.pieces) self.pieces = [] return [nal] elif nal_type == 24: # STAP-A nals = [] off = 1 while off < len(payload): size = int.from_bytes(payload[off:off+2], 'big') off += 2 nals.append(payload[off:off+size]) off += size return nals else: # 单 NAL:直接就是完整单元 return [payload] return None

这里有两个细节值得注意。第一,缺口检测必须用(seq - self.last_seq) & 0xffff,而不是直接比较大小或算绝对值——RTP 序号是 16 位无符号,65535 之后会回绕到 0,直接减法会算出负数,让丢包判断彻底失真。第二,start 分片到 end 分片之间如果出现过缺口,这个 FU-A 拼出来的 NAL 已经是坏的,与其交给 MP4 索引,不如直接丢弃,等下一个关键帧重新开始。这个「宁可丢一帧,不可错一帧」的原则,在后面排查章还会反复出现。

真正写合成器的时候,还要把 RTP 头的解析放在外面做:版本号、CC(CSRC 计数)、marker 位、payload type、sequence、timestamp、SSRC 各占多少位是固定的。marker 位尤其有用——很多发送方在视频帧的最后一个 RTP 包上置 marker,它可以作为「一帧收齐」的辅助信号,和 FU-A 的 end 位互相印证。

2.3 AAC 走 RFC 3640:AU-header 决定切片边界

AAC 在 RTP 里的封装和 H.264 完全不同。H.264 靠 NAL header 自描述,AAC 则把一帧叫做一个 Access Unit(AU),每个 RTP 包里可能有一个或多个 AU,包前面先放一块 AU-header 区域,描述每个 AU 的长度。这块区域的前 16 bit 是总位数,后面每个 AU 的 size 字段占 sizeLength 位(SDP fmtp 里约定了,默认 13)。

def read_bits(data, bitpos, count): val = 0 for _ in range(count): byte = data[bitpos >> 3] bit = (byte >> (7 - (bitpos & 7))) & 1 val = (val << 1) | bit bitpos += 1 return val, bitpos def split_au(payload, size_length=13, index_length=3): # RFC 3640:前 16 bit 是 AU-headers 总位数(单位是 bit) au_headers_len_bits = int.from_bytes(payload[0:2], 'big') au_headers_len_bytes = (au_headers_len_bits + 7) // 8 bitpos = 16 # AU-headers 从第 16 bit 开始 au_sizes = [] while bitpos < 16 + au_headers_len_bits: au_size, bitpos = read_bits(payload, bitpos, size_length) au_sizes.append(au_size) # indexLength 位是 AU-Index / AU-Index-delta,切片用不到但要跳过去 _, bitpos = read_bits(payload, bitpos, index_length) body = payload[2 + au_headers_len_bytes:] frames, pos = [], 0 for sz in au_sizes: frames.append(body[pos:pos + sz]) pos += sz return frames

这段代码的坑在于位操作。AU-headers-length 的单位是 bit 不是字节,所以第一步就要除以 8 对齐;而每个 AU-size 字段是从 bit 16 开始逐位读的,不是从字节 2 开始对齐读。很多实现图省事直接int.from_bytes(payload[2:4])当 size 用,遇到多个 AU 在一个包里的流就会把切片边界算错,后面合成的 AAC 全是噪音。

AAC 的 RTP 时间戳也有讲究:它直接以采样率为时钟,48kHz 采样率下,每帧 1024 个样本,所以相邻两帧的 RTP 时间戳差值恒为 1024。如果看到差值不对,说明要么丢帧了,要么 AU 切片切错了。这一步的时间和 H.264 的视频时钟域完全不同,正是第 4 章要处理的换算问题。

3. MP4 容器:moov 里的样本表才是 MP4 的「灵魂」

3.1 盒子骨架:ftyp、moov、mdat 谁先谁后

MP4 是一棵树形的盒子结构,根上至少三个盒:ftyp 声明文件类型和兼容品牌;moov 是元数据仓库;mdat 装真正的媒体裸流数据。「先索引后数据」是最理想布局,但合成器写文件时往往先拿到了数据才算出索引,所以实际常见顺序是 ftyp + moov + mdat(faststart 布局),或者 ftyp + mdat + moov(数据在前、索引在后)。

两者播放器都能打开,差别在随机访问。索引在后的文件,播放器必须读完整个文件才能拖进度条,边下边播的场景直接卡死。第 6 章会讲两种布局各自怎么实现。

moov 内部最关键的是 mvhd(电影头)和每轨一个 trak。trak 里嵌套 tkhd(轨道头)、mdia/mdhd(媒体头,声明本轨的 timescale)、hdlr(轨道类型,soun 或 vide)、minf/stbl(样本表)。样本表是 MP4 最核心的数据结构,包含五张表:

  • stsd:每个样本条目的编码配置,视频轨放 avcC,音频轨放 esds。
  • stts:样本时长表,用「段计数 + 段时长」的游程编码描述每个样本显示多久。
  • stsz:每个样本的字节数,一张普通数组。
  • stsc:样本到块的映射,描述每个 chunk 里放几个样本。
  • stco:每个 chunk 在 mdat 里的绝对文件偏移。

播放器实际上只做三件事:用 stsd 知道怎么解码,用 stco+stsc 知道去哪读数据,用 stts+stss 知道按什么节奏显示。合成器的工作就是把 RTP 还原出来的每一帧填进这三件事对应的表。

3.2 avcC 与 AudioSpecificConfig:编解码参数如何进制表示

先看 H.264。MP4 要求视频样本用 AVCC 格式存储:每个 NAL 前加一个长度前缀(常见 4 字节),不允许出现 Annex-B 的起始码00 00 00 01。RTP 负载里的 NAL 天然没有起始码,所以这条反而省事——但要小心:如果发送方在 payload 里塞了起始码(部分实现在单 NAL 模式下会这么做),你要先剥掉再入样本。

SPS 和 PPS 不放进每个样本,而是提取出来写进 stsd 的 avcC 盒子:

def build_avcc(sps, pps): # AVCDecoderConfigurationRecord 的字节布局 avcc = bytearray() avcc.append(1) # configurationVersion,固定 1 avcc.append(sps[1]) # AVCProfileIndication,来自 SPS 字节 1 avcc.append(sps[2]) # profile_compatibility,来自 SPS 字节 2 avcc.append(sps[3]) # AVCLevelIndication,来自 SPS 字节 3 avcc.append(0xff) # lengthSizeMinusOne = 3,即 4 字节长度前缀 avcc.append(0xe0 | (1 & 0x1f)) avcc.extend(len(sps).to_bytes(2, 'big')) avcc.extend(sps) avcc.append(1) avcc.extend(len(pps).to_bytes(2, 'big')) avcc.extend(pps) return bytes(avcc)

0xff这一字节值得解释:低 2 位是 lengthSizeMinusOne,值为 3 表示长度前缀占 4 字节;高 6 位是保留位,标准要求全 1。0xe0 | 1表示 numOfSequenceParameterSets 为 1。SPS 和 PPS 各带 2 字节长度前缀,注意这里是「大端 + 不带自身」的长度——就是你提取时的原始字节数。

AAC 的配置叫 AudioSpecificConfig(ASC),是一个比 avcC 更精简的二进制结构:5 bit 的 audioObjectType,4 bit 的 samplingFrequencyIndex,4 bit 的 channelConfiguration。AAC-LC 的 audioObjectType 是 2,48kHz 对应 index 3,44100Hz 对应 index 4,双声道 channelConfiguration 是 2。

def build_asc(sample_rate, channels, aot=2): sr_index = {96000:0, 88200:1, 64000:2, 48000:3, 44100:4, 32000:5, 24000:6, 22050:7, 16000:8, 12000:9, 11025:10, 8000:11}[sample_rate] val = (aot << 11) | (sr_index << 7) | (channels << 3) return val.to_bytes(2, 'big')

这段配置怎么来?两个途径:一是解析 SDP 的 fmtp 行,AAC 的 config 参数本身就是十六进制 ASC,比如config=1210直接解出 44.1kHz 立体声;二是从 RTP 里的 ADTS 头解析——ADTS 头的第 2、3 字节就带采样率和声道信息。build_asc生成的 2 字节要再包一层 esds 盒子放进音频轨的 stsd,结构上是esds->ES_Descriptor->DecoderConfigDescriptor->DecoderSpecificInfo,后者装的就是 ASC。

SPS 解析比 ASC 复杂得多,因为宽高是靠 Exp-Golomb 编码藏在位流里的。下面是个能跑通的解析片段,只取算宽高需要的字段:

def read_ue(bits, pos): zeros = 0 while bits[pos] == 0: zeros += 1 pos += 1 pos += 1 # 跳过第一个 1 value = (1 << zeros) - 1 for _ in range(zeros): value = (value << 1) | bits[pos] pos += 1 return value, pos def parse_sps(sps): profile = sps[1] level = sps[3] bits = [] for b in sps[4:]: bits += [(b >> i) & 1 for i in range(7, -1, -1)] pos = 0 _, pos = read_ue(bits, pos) # seq_parameter_set_id if profile in (100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135): # 高 profile 才有扩展字段 chroma, pos = read_ue(bits, pos) # chroma_format_idc if chroma == 3: pos += 1 # separate_colour_plane_flag _, pos = read_ue(bits, pos) # bit_depth_luma_minus8 _, pos = read_ue(bits, pos) # bit_depth_chroma_minus8 pos += 1 # qpprime_y_zero_transform_bypass_flag scaling, pos = read_ue(bits, pos) # seq_scaling_matrix_present_flag if scaling: # delta scale 序列,重建用得上但算宽高可直接跳过 for i in range(8 if chroma != 3 else 12): if bits[pos]: pos += 1 pos += (16 if i < 6 else 64) * 8 else: pos += 1 _, pos = read_ue(bits, pos) # log2_max_frame_num_minus4 pct, pos = read_ue(bits, pos) # pic_order_cnt_type if pct == 0: _, pos = read_ue(bits, pos) # log2_max_pic_order_cnt_lsb_minus4 elif pct == 1: pos += 1 # delta_pic_order_always_zero_flag pos += 2 # offset_for_non_ref_pic(se(v),这里直接跳过) pos += 2 # offset_for_top_to_bottom_field n, pos = read_ue(bits, pos) pos += n * 2 # num_ref_frames 循环 _, pos = read_ue(bits, pos) # max_num_ref_frames pos += 1 # gaps_in_frame_num_value_allowed_flag w_mbs, pos = read_ue(bits, pos) # pic_width_in_mbs_minus1 h_mbs, pos = read_ue(bits, pos) # pic_height_in_map_units_minus1 fmo = bits[pos] # frame_mbs_only_flag return (w_mbs + 1) * 16, (2 - fmo) * (h_mbs + 1) * 16, profile, level

注意两个易错点:SPS 的字节 4 之后才是位流,字节 0 是 NAL header(0x67 是 SPS 的固定头);frame_mbs_only_flag为 0 时高度要乘 2,隔行视频的高是 2 倍 map units。写进 avcC 的 SPS 是原始字节,不要拿解析后的值重新编码——一切以源字节为准。

3.3 样本表四件套:stts、stsz、stsc、stco 怎么算出来

现在手里有一串样本:每个样本有(RTP 时间戳、字节数、在文件中的偏移、是否是关键帧)。样本表就是从这串数据压出来的。最核心的洞察是:stts 和 stsc 都是游程编码,不是存数组,而是存「连续多少个相同的值」。

def pack_runs(values): # [3600,3600,3600,3200,3200] -> [(3,3600),(2,3200)] runs, i = [], 0 while i < len(values): j = i while j < len(values) and values[j] == values[i]: j += 1 runs.append((j - i, values[i])) i = j return runs

stts 用这个方法压时长;stsc 的语义是「从第 N 个 chunk 到第 M 个 chunk,每个 chunk 有 K 个样本」;stsz 则必须存每个样本的真实大小,因为它要精确到字节,没法压。stco 存每个 chunk 在 mdat 里的起始偏移——这是整棵树里最容易写错的位置,因为它在 mdat 内容没写完之前是未知的。

合成器实际工作是按「chunk」组织的:把几个视频帧或一段音频帧攒成一个 chunk,一次性写入 mdat,记录这个 chunk 的起始偏移到 stco,stsc 登记这个 chunk 里样本数量。音频通常一个 chunk 塞几十个帧,视频一个 chunk 就一帧,这样交错写入保证音画读取时不会跳来跳去。写 mdat 时每写一个样本就记下它的偏移和大小,最后统一生成 moov——这是「两遍写」的通用流程,具体落盘策略在第 6 章展开。

4. 时间戳与同步:两个时钟域怎么对齐成一个播放节奏

4.1 RTP 时间戳的时钟语义:视频 90kHz、音频用采样率

RTP 时间戳不是秒,也不是毫秒,而是所属媒体时钟的 tick 数。视频惯例用 90kHz(RFC 3551 建议视频时钟频率取 90000 的约数倍数),音频直接用采样率:48kHz 的流,时间戳每跳 1 就是 1/48000 秒。所以对着两个流的 RTP 时间戳直接做减法没有任何意义——视频的 3600 和音频的 1024 根本不是一个单位。

换算公式只有一条:elapsed_seconds = (ts_now - ts_base) / clock_rate。合成器要做的是把这个秒数再乘回自己选的 MP4 timebase,得到样本时长。这里有个关键决定:视频轨的 mdhd timescale 选多少。选 90000,则视频样本时长直接等于 RTP 时间戳差,零换算误差;选 1000 这种「整齐」的数,每帧都要做一次四舍五入,累积起来就是音画漂移的来源。

def durations_from_rtp(ts_list, clock_rate, timescale): res = [] for prev, cur in zip(ts_list, ts_list[1:]): delta = (cur - prev) & 0xffffffff # 模 2^32,处理回绕 res.append(round(delta * timescale / clock_rate)) return res

& 0xffffffff这一行是长时间录制的保命符。RTP 时间戳是 32 位无符号,90kHz 下约 13.26 小时回绕一次。直接写cur - prev在回绕瞬间会得到一个巨大的负数,stts 表里就出现一个负时长,播放器直接行为异常。模运算让差值永远落在正确的 0 到 2^32-1 区间。

4.2 换算到 MP4 时间基:duration 和 stts 的填充规则

确定了 timescale 之后,样本时长就定下来了。视频在 25fps 下,90kHz 时钟每帧3600,stts 的游程编码会得到(N, 3600)一条记录。音频 48kHz 下每帧 1024 样本,AAC 帧长固定 1024 个采样点,所以音频轨永远只有一条 stts 记录。音频轨的 timescale 直接设 48000,时长字段就是 1024,不需要任何换算。

遇到 B 帧时还要补一张 ctts 表。stts 存的是解码顺序下的时长,有 B 帧时显示顺序和解码顺序不一致,需要 ctts 记录每个样本相对解码时刻的偏移。没有 B 帧的流可以完全不写 ctts,这也是一般 RTP 直播流最常见的配置——低延迟场景普遍不开 B 帧。

写 moov 时还有一对时长要填:mvhd 的 duration 是本轨样本时长之和,mdhd 的 duration 同理,都必须用本轨 timescale 的 tick 数。用秒数直接写是新手最容易犯的错——mp4 里所有时长字段都不带单位,单位由对应的 timescale 决定,填错单位整个播放节奏全乱。

4.3 RTCP SR 与多轨对齐:没有统一基准时音画同步靠什么

到这一步,音频和视频各自有了内部的相对节奏,但两轨之间的零点还没对齐。RTP 流的天然对齐工具是 RTCP SR(发送端报告):它周期性地把某个 RTP 时间戳映射到 NTP 墙上时间,相当于给媒体时钟提供了一个绝对基准。

def parse_sr(packet): # RTCP SR 的固定头:V=2, PT=200,从偏移 8 开始是 NTP 时间戳 if packet[1] != 200: return None ntp_hi = int.from_bytes(packet[8:12], 'big') ntp_lo = int.from_bytes(packet[12:16], 'big') ntp = ntp_hi + ntp_lo / 2**32 rtp_ts = int.from_bytes(packet[16:20], 'big') return ntp, rtp_ts # 已知 (ntp0, rtp0) 映射对,求任意 rtp_ts 的墙上时间 def rtp_to_wall(ntp0, rtp0, clock_rate, rtp_ts): return ntp0 + ((rtp_ts - rtp0) & 0xffffffff) / clock_rate

拿到视频轨和音频轨各自的 SR 映射后,选一个基准 NTP 时刻,把两轨的零时刻都对齐到它,音画就同步了。没有 SR 的场景(比如只抓了单向媒体流),退而求其次用到达时间做基准:以音频第一个样本为零点,视频按到达时刻的相对位置换算。这个方案在抖动大的网络里会有几百毫秒的误差,但比完全不做对齐强。真正做录制的系统,SR 解析和 RTP 解包一样要放在主循环里,因为它不只是用来对齐,还能用来校正网络漂移——如果音频 RTP 时间戳增长速率长期快于 NTP 推算值,说明发送端采样时钟有偏,长时间录制必须按 SR 来微调每帧时长。

5. 常见问题排查:花屏、打不开、不同步的五个典型现场

5.1 首帧花屏,开头几秒一片绿

现象:文件能打开,视频前几秒花屏或绿屏,后面恢复正常。

原因:RTP 流加入的时刻不一定是 GOP 起点。如果录制从某个 P 帧开始,这个 P 帧依赖的前一个 IDR 根本不在文件里,而合成器仍然把它标记为同步样本写进了 stss,播放器从它开始解码,自然解出马赛克。

解决:严格按「等 IDR 再开写」的规则处理。收到非 IDR 帧但尚未收到任何 IDR 时,直接丢弃,直到 nal_unit_type 为 5 的样本出现才开始构建文件。stss 表只登记真正完整的 IDR 样本。这样做的代价是文件开头可能丢掉几帧,换来的是可播放性。

5.2 文件打不开或拖拽卡死

现象:文件能正常播放开头,拖进度条就转圈甚至闪退,有的播放器直接提示文件损坏。

原因:两个独立问题叠加。一是 moov 写在了 mdat 后面且没有 faststart 重排,播放器必须读全文件才能建立随机访问索引;二是更隐蔽的——合成时 stco 的偏移在 mdat 还没写完时就已经写入文件,之后数据变长,所有后续 chunk 的偏移整体错位。

解决:见第 6 章。核心顺序是「先写 moov 占位或先攒齐数据再算 stco」,绝不在 mdat 写入过程中固化 stco。已经写坏的文件,唯一的后悔药是重新合成时修正写盘顺序,别试图手工修 stco 偏移——样本数一多,人工根本算不过来。

5.3 长时间录制后音画逐渐不同步

现象:前半小时音画正常,两小时后视频比音频慢 2 秒,越往后越严重。

原因:最常见的不是采样漂移,而是 32 位时间戳回绕加上差值的无符号运算缺失。90kHz 下 13.26 小时回绕一次,回绕瞬间直接减法得到负 40 多亿 tick,换算成秒是负的,stts 里出现负时长。另一种可能是音频时钟真实漂移,但那个量级通常很小,几小时才显现几百毫秒,先查代码再怀疑硬件。

解决:所有 RTP 时间戳差值一律用(cur - prev) & 0xffffffff。这条已经在 4.1 节给出,但它值得放在排查清单第一位——因为它影响的不只是同步,还会让 stts 表产生非法数据,整个 stbl 解析直接崩。从那以后我每写流处理代码都会先搜一遍有没有裸减法。

5.4 AAC 输出全是噪音

现象:画面正常,音频是「沙沙」的白噪音,或者语速明显变快/变慢。

原因:AU-header 解析错了。最常见的是没看 SDP 里 fmtp 的 sizeLength 参数就默认 13——不少实现用 12 或 16,按 13 去读每位 size 字段,切片边界全错。另一个高发原因是源流是带 ADTS 头的 AAC,payload 前 7 或 9 字节是 ADTS header,合成时没有剥掉,直接把 ADTS 当 AU 塞进 MP4,而 MP4 的 mp4a 样本要求裸 AAC。

解决:解析 SDP 时把sizeLength、indexLength和config全部取出来缓存;收到 payload 先检查前 12 bit 是否为0xFFF——是则说明带 ADTS 头,按帧长字段剥掉头部再走 AU-header 流程。拿不准时先把首包打印成 hex,人工确认一下再写自动化逻辑。

5.5 丢包导致 NAL 错位,文件变成「半哑弹」

现象:画面整体正常,播放到某一段突然卡死,抓包看这一段有明显的 seq 缺口。

原因:FU-A 重组时没有做缺口检测,中间丢了一片,后面另一帧的分片被接在残缺的 NAL 后面。重组出来的「伪 NAL」长度前缀和真实内容对不上,MP4 的 stsz/stco 索引基于这些错误长度建立,播放器读到这段就去 mdat 的错误偏移取数据,直接崩。

解决:2.2 节代码里的(seq - self.last_seq) & 0xffff != 1检测不是可选项。一旦发现缺口,清空当前 FU-A 累积区,并且置一个「跳过标志」,直到下一个 IDR 到达才恢复成帧。代价是丢失一整个 GOP,换来的是文件在任何播放器上都能完整播放到坏点之前。流媒体录制的血泪经验就一条:坏数据进入索引表,比丢数据严重得多。

6. 设计取舍:两遍写盘、占位回填,还在边攒边 flush

6.1 两遍写:先全部样本后 moov,稳但吃存储

最简单的策略:第一遍把所有样本写进一个临时文件(或者干脆留内存),记录每个样本的 size、duration、offset 和是否关键帧;等流结束,把这些元数据组装成 moov,最后把 moov 和新 mdat 一起写到成品文件。这个方案的切换成本最低,逻辑最不容易错,适合做产品原型和短时录制。

代价有两个。第一是峰值存储翻倍——临时文件加成品文件。第二是元数据全量驻留内存,录得越久,stsz 数组和 stco 数组越长,几百万样本的数组在内存里也是真金白银。内存在长录场景下不是无限资源,所以长录音视频系统几乎不用两遍写。

6.2 占位回填:预留 moov 空间,写完后重写 stco

工程上更常见的是边收边写:先写 ftyp,再预留一块 moov 的位置(实际是先写一个足够大的 free 盒子占位),然后写 mdat。等流结束时 moov 已经算好,把它写进预留区,如果实际 moov 比预留小,剩余空间仍然是合法的 free 盒子;如果比预留大,就得把 moov 写到文件末尾,mp4 文件也允许 moov 在 mdat 后面,只是随机访问性能差。

def patch_stco(f, stco_entries): # stco_entries: [(box_file_offset, [chunk_offset, ...]), ...] for box_off, offsets in stco_entries: f.seek(box_off + 8) # 跳过 size 和 type,直接写 entry_count f.write(len(offsets).to_bytes(4, 'big')) for off in offsets: f.write(off.to_bytes(4, 'big'))

真正的难点在预估 moov 大小,它取决于样本数和 stts 游程条数。实践中按「单样本 16 字节 + 每条游程 16 字节 + 固定部分 4KB」估算,超长录制宁可把 free 盒子开得大一些——free 盒子大一点没有任何副作用,小了反而要重排整个文件。占位回填适合长时间录制和边收边落盘的产品,监控录像这类「永远不结束」的场景基本只剩这个选项。

6.3 三类方案的对比与我的习惯

方案峰值内存额外存储适合场景stco 是否需要回填
两遍写高(全部样本元数据)高(临时文件)短时录制、原型验证否
占位回填低(仅当前 chunk)低长时间录制、边收边写是
片段式(fMP4)低低DASH 分片、直播存档否,但播放器兼容性要求高

这三者的取舍,本质是「什么时候知道 moov」和「愿意为谁的确定性买单」。两遍写用存储换确定;占位回填用预估换内存;fMP4 片段式把 moov 拆散成一个个 moof,规避了大 moov 问题,但普通播放器对 moof 的支持参差,本方案不合用时就别硬上。

让我把 2.2 节的缺口检测、4.1 节的模运算差值和 6.2 节的 stco 回填顺序,视为 MP4 合成的三个不可妥协条件。从那以后,我每写一个合成器,都强制先跑通「10 秒单视频轨 + 无 B 帧 + 拖拽正常」的最小用例,再放开到 AAC、B 帧和几小时的长时间录制,因为这条链路里所有「玄学问题」,最后都会落在索引和时间戳这两个最容易藏 bug 的地方。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进