简介:这份资源是一套用C语言实现H.264视频裸流与AAC音频数据打包成TS格式的示例工程,面向流媒体开发、音视频编解码及网络传输方向的工程师与学习者。资源共3个文件,含2个C源文件和1个头文件,压缩包仅13KB,体积小巧,便于快速阅读与集成。示例覆盖了NAL单元切分、起始码添加、TS包头构造与填充字节生成、PID分配等关键环节,对SPS/PPS等关键NAL单元的处理也给出了相应策略,并针对188字节TS包与可变长NAL/AAC帧的适配提供可运行代码,可在RTSP推流、IPTV、视频直播等场景中参考扩展。目前已有2126人学习浏览,适合具有基础C语言与音视频概念、希望深入理解TS封装原理的开发者。读者对照代码即可梳理从H.264裸流到TS包的完整数据流,理解多路复用与PID管理的基本思路,并在此基础上进一步实现数据检测、同步与封装优化。
1. 先搞清楚“H.264视频裸流”与“TS”之间到底隔了什么
你手上有两段原始数据:一段是H.264编码后的视频裸流,通常叫Annex-B格式,文件里全是一个接一个的NALU,每个NALU以00 00 01或00 00 00 01开头;另一段是AAC编码后的音频裸流,每帧以FF F1或FF F9这样的ADTS同步字开头。你想要的“打包成TS”,就是要把这两段毫不相干的字节流,按MPEG-TS的容器规范重新组织成一个后缀为.ts的文件或实时流,让播放器、解码器、CDN都能认出来、播得动、音画能对上。
很多从业者第一次做这个,是卡在“播放器不认裸流”这步。你把H.264裸流直接命名成.mp4打不开,用VLC直接拖.264后缀的文件也会提示无法识别。TS容器的作用,就是把音频和视频的字节流切成固定大小的包(每包188字节),在每个包里打上PID标签,再在流里周期性地插入节目关联表(PAT)和节目映射表(PMT),让解码器知道“哪个PID是视频、哪个PID是音频、时间戳放在哪里”。这篇文章会带你完整走一遍从裸流到合法TS的封装链路,包括PES封装、PSI表构造、时间戳计算和调试方法。适合正在做录制直播流、拉流转封装、或写自定义推流模块的开发者。
2. 拆开TS包的188字节:同步字节、PID、PES和PSI
这一章先解决“TS到底长什么样”的问题。封装TS之前,你得能读懂一段TS字节流,否则后面定位问题连门都找不着。我会先讲包结构,再给出一个能解析TS包头的Python脚本,让你对输出结果有直观判断。
2.1 TS包的四层结构:从0x47到PES payload
TS流是由一个个固定188字节的TS包组成的。每个TS包以同步字节0x47开头,这样播放器可以在数据流里搜索0x47来同步位置。0x47后面紧跟着2字节的包头信息,拆完这3字节后,剩下的185字节里,可能带适配字段(adaptation field),也可能不带,纯payload的TS包是播放器能最快解析的形态。
一个TS包的头部信息按位拆开是这样的:同步字节占1字节,然后transport_error_indicator占1bit、payload_unit_start_indicator占1bit、transport_priority占1bit、PID占13bit,再往后是transport_scrambling_control占2bit、adaptation_field_control占2bit、continuity_counter占4bit。其中payload_unit_start_indicator位很重要,它告诉解析器“这个包携带的payload是不是一个PES包的起始”,只有在这个位置为1时,TS payload的开头才会出现完整的PES头;后续包则直接接着跟数据。搞清楚这个位,是写打包器最关键的认知转变。
PID是整个TS流的核心寻址方案。视频、音频、PAT、PMT各自用一个数字标识,彼此靠PID隔离互相冲突。PAT固定是0x0000,PMT的PID由PAT里的节目条目指定,视频和音频的PID则写在PMT里。标准里PID有预定义值,比如PAT是0、空包是8191之类,但视频和音频PID没有强制值,常见做法是自定,比如0x0100视频、0x0101音频。只要PAT和PMT里声明一致,播放器一概认。
2.2 用一段Python代码解析TS包:验证你看到的不是乱码
写打包器之前,我习惯性地先写一个解析脚本来观察自己产出的流。用Python读取TS文件,找到第一个0x47同步字,然后按188字节一格去扫描。每一格都尝试解析包头,并把PID、起始标志、适配字段控制位和计数打出来。代码非常简单,但能干活。
import struct import sys def parse_ts_header(packet: bytes): if packet[0] != 0x47: return None # 按大端读取2字节包头 b1 = packet[1] b2 = packet[2] transport_error = (b1 >> 7) & 0x01 payload_unit_start = (b1 >> 6) & 0x01 transport_priority = (b1 >> 5) & 0x01 pid = ((b1 & 0x1F) << 8) | b2 scrambling = (packet[3] >> 6) & 0x03 adaptation_field_control = (packet[3] >> 4) & 0x03 continuity_counter = packet[3] & 0x0F return { "pid": pid, "payload_unit_start": payload_unit_start, "adaptation_field_control": adaptation_field_control, "continuity_counter": continuity_counter, "scrambling": scrambling } with open(sys.argv[1], "rb") as f: data = f.read() # 扫描同步字 sync_pos = data.find(b'\x47') if sync_pos == -1: print("没有找到TS同步字节") sys.exit(1) # 从sync_pos开始按188字节遍历 for i in range(sync_pos, len(data) + 1 - 188, 188): pkt = data[i:i+188] h = parse_ts_header(pkt) if h is None: print(f"位置 {i} 处的同步字不合法") break if h["pid"] in (0x0000, 0x0010, 0x0011, 0x0100, 0x0101): # 只打印关心的PID print(f"pos={i} pid=0x{h['pid']:04X} " f"pusi={h['payload_unit_start']} " f"afc={h['adaptation_field_control']} " f"cc={h['continuity_counter']}")这段代码的逻辑:先找同步字节,然后每188字节跳格。每次跳格前都校验0x47,只要有一个位置的同步字节不对,说明要么文件不是TS流,要么中间混入非TS数据。这里有个参数值得留意:adaptation_field_control取2时表示只有适配字段没有payload,取3时是“适配字段+payload”,取1时是纯payload。你的打包器必须处理好这三种情况,否则解码器可能在某个包上直接丢掉后续同步。
2.3 PAT和PMT:注册表与目录,解码器靠它们“按图索骥”
PAT(节目关联表)是整个TS流的第一张信息表,它的内容是“节目号对应PMT的PID”。PMT(节目映射表)再告诉解码器“该节目里音频、视频分别是哪个PID,分别是什么编码格式,时间戳字段在哪里”。这两张表周期性重复发送,解码器启动后通常在几十毫秒内就能捕获到第一份PAT,然后靠program_map_PID字段去抓PMT。
写打包器时,PAT和PMT的结构有固定的表格ID和section语法,我一般用一组常量来生成,不会每次从头位操作拼出来。下表是构造时必需的字段值,打包时一个都不能少:
| 字段 | PAT取值 | PMT取值 |
|---|---|---|
| table_id | 0x00 | 0x02 |
| section_length | 取决于内容长度,一般0x0D左右 | 取决于流数量,计算得出 |
| program_number | 自定义,一个节目用1 | 必须等于PAT里声明的节目号 |
| PCR_PID | 无 | 指向视频PID(通常放在视频流) |
| stream_type | 无 | 0x1B表示H.264,0x0F表示AAC |
| elementary_PID | 无 | 指向视频或音频PID |
写入顺序也有讲究:PAT第一个发出,PMT紧随其后,随后才是视频和音频的PES包。播放器如果先看到视频PES包但尚未见过PMT,会丢帧直到同步到PMT。因此录制头几十个TS包时,PAT/PMT的重复频率和首包位置决定了播放器起播延迟。
3. 从H.264裸流构建视频PES:NALU处理、SPS/PPS与时间戳基准
TS容器本身并不认识“帧”,它只认PES包。你要做的,是把H.264的若干个NALU组装成一个PES包,再切进TS包。这里最核心的坑在NALU的切分边界、SPS/PPS参数集的插入位置,以及PTS/DTS的换算。这一章我按实际打包顺序展开。
3.1 NALU边界识别与Annex-B字节流遍历
H.264视频裸流常见的是Annex-B格式,NALU之间用起始码分隔。起始码分两种:三字节00 00 01和四字节00 00 00 01。四字节常出现在流的开头、SPS/PPS之前,三字节多出现在普通帧边界。很多不成熟的工具在切NALU时只搜三字节起始码,结果把四字节起始码的前缀当成上一个NALU的尾巴,导致第一个NALU多出两个字节,解码器直接报丢失。切分时先尝试匹配四字节,再降级匹配三字节,能有效规避这个问题。
M3 = b'\x00\x00\x01' M4 = b'\x00\x00\x00\x01' def annexb_nalus(data: bytes): start_codes = [] i = 0 while i < len(data): if data[i:i+4] == M4: start_codes.append((i, 4)) i += 4 elif data[i:i+3] == M3: start_codes.append((i, 3)) i += 3 else: i += 1 # 根据起始码位置切NALU nalus = [] for idx, (pos, size) in enumerate(start_codes): end = start_codes[idx+1][0] if idx+1 < len(start_codes) else len(data) nalu = data[pos+size:end] # 每个NALU至少包含1字节的NAL header if len(nalu) >= 1: nalus.append(nalu) return nalus这段代码的真正价值在于:它把起始码开销剥离,输出纯粹是NALU内容。type = nalu[0] & 0x1F就能得到类型,比如7表示SPS、8表示PPS、5表示IDR帧。后面你做过滤和注入,都建立在正确切分的基础上。需要注意遍历时i += 3或i += 4的移动步长不能随意简化,否则连续起始句会一边漏一边错位。这个逻辑看似简单,但在处理整段视频文件时会暴露出很多边界问题,尤其是码流末尾的数据不完整时。
3.2 SPS/PPS注入策略:关键帧之前必须见到“参数集”
解码器在解码H.264之前,必须先拿到SPS和PPS。如果TS流里第一帧就是IDR但前面没有SPS/PPS,播放器会一直黑屏,直到某个逻辑上“不该出现SPS”的位置突然插入SPS/PPS才恢复。标准做法是:在每一个IDR帧对应的PES包之前,把SPS和PPS作为存取单元的开头无缝塞入,这样播放器每遇关键帧都能重新初始化解码器。
def build_video_pes(nalus: list, pts_90k: int, dts_90k: int, is_keyframe: bool, sps: bytes, pps: bytes) -> bytes: """ 将NALU列表封装成一个PES包。 nalus: 当前帧的所有NALU,按顺序排列 pts_90k / dts_90k: 以90kHz为单位的时间戳 is_keyframe: 是否IDR帧 sps / pps: 缓存的SPS和PPS原始字节(不含起始码) """ prefix = [] if is_keyframe: prefix = [sps, pps] # 组装PES payload if prefix: payload = b'\x00\x00\x00\x01' + prefix[0] + b'\x00\x00\x00\x01' + prefix[1] else: payload = b'' for nalu in nalus: payload += b'\x00\x00\x01' + nalu pes_len = len(payload) + 8 # PES头固定8字节 # PES头构造 header = bytearray() header += b'\x00\x00\x01' + b'\xE0' # stream_id视频 header += (pes_len >> 8) & 0xFF header += pes_len & 0xFF # 标志位:PTS_DTS_flags = 3 表示PTS和DTS都有 header += 0x80 | 0x40 | 0x20 # 10: 仅PTS, 11: PTS+DTS header += 0x0A # header_data_length # 填充PTS header.append((0x30 | ((pts_90k >> 29) & 0x07)) & 0xFF) header.append((pts_90k >> 22) & 0xFF) header.append(((pts_90k >> 14) & 0xFE) | 1) header.append((pts_90k >> 7) & 0xFF) header.append(((pts_90k << 1) & 0xFE) | 1) # 填充DTS header.append((0x10 | ((dts_90k >> 29) & 0x07)) & 0xFF) header.append((dts_90k >> 22) & 0xFF) header.append(((dts_90k >> 14) & 0xFE) | 1) header.append((dts_90k >> 7) & 0xFF) header.append(((dts_90k << 1) & 0xFE) | 1) return bytes(header) + payload参数说明里值得关注的两个数字:0x80 | 0x40 | 0x20这一行,0x80是PES头内保留位为1,0x40表示原始数据匹配,0x20表示PTS是否存在。如果只需要PTS不需要DTS,这里改成0x80 | 0x40就行,但H.264视频一般建议同时给DTS。PTS和DTS的嵌位格式比较特殊,5字节里实际只有33位有效数据,所以每个字节的最后一位都被强行置1,这是MPEG规范的历史遗留,写错一个bit解码器就直接丢弃这个时间戳。
3.3 PTS/DTS的计算基准与起点选择
时间戳似乎是TS封装里大多数人最容易翻车的地方。PTS和DTS都以90kHz为频率,也就是一秒分成90000个刻度。你从视频帧率推导:25fps视频,每帧时长是90000 / 25 = 3600个刻度;59.94fps则每帧约1501.5个刻度,按整数递增会有累积误差。我通常会维护一个整数计数和浮点累加器,每次先按浮点累加再取整,用来避免长时间录制的漂移。
初始化起点时,不要把第一个PTS设为0。我们做直播录播流,第一个视频PTS从0开始的话,音频的起始PTS往往也是0附近的某个值,两个0放在一起没问题,但一旦首帧是B帧就会造成DTS小于0的非法情况。常见做法是给第一个视频帧设一个较大的基准值,比如90000,或者90000的整数倍。这条经验能让VLC在播放时立刻起播,而不是在一个异常时间戳上卡一两秒。
DTS的写入逻辑更关键。如果码流里存在B帧,DTS与PTS不同,DTS代表解码顺序,PTS代表显示顺序。TS封装里PES头可以同时携带这两个值,不写DTS时解码器会默认DTS = PTS。如果H.264有明显的B帧层级,不写DTS的后果是播放器把B帧按显示顺序送进解码器,解码器内部的参考帧管理立刻乱掉,几帧之后花屏。
4. AAC音频裸流的接入:ADTS帧解析、PES封装与音画对齐
视频PES已经能切进TS包了,接下来要接音频。AAC在TS容器里不是以原始AAC裸数据流直接出现的,而是要走PES封装。TS标准里AAC对应stream_type 0x0F。这里最麻烦的不是PES封装,而是ADTS的帧长解析和音频PTS的计算。
4.1 从FF F1同步字开始逐帧切AAC
AAC裸流通常被存储为ADTS格式,每一帧都以12字节的ADTS头开头,其中包含采样率、声道数、帧长。帧长字段是关键,位置在ADTS头的固定模板里,不是sample_rate_index那一位,而是跨了第4到第6字节的若干bit。解析错误会让帧边界错位,后面全部乱掉。切帧时要循环地用“同步字+帧长”往下跳。
def parse_adts(data: bytes, offset: int): if data[offset] != 0xFF: return None head = data[offset:offset+2] if (head[0] & 0xF6) != 0xF0: # syncword + layer 00 # 0xF6屏蔽MPEG版本和layer位后应为0xF0 return None # 拿到帧长 length = ((data[offset+3] & 0x03) << 11) | \ (data[offset+4] << 3) | \ ((data[offset+5] & 0xE0) >> 5) return length def cut_adts_frames(data: bytes): frames = [] i = 0 while i < len(data): length = parse_adts(data, i) if length is None: i += 1 continue frame = data[i:i+length] if len(frame) == length: frames.append(frame) i += length else: break return frames0xF6这个校验逻辑值得解释:ADTS首字节是0xFF,第二字节高四位是MPEG版本和layer,AAC对应的值是0xF0或0xF1,用head[0] & 0xF6等于0xF0来过滤,能挡住绝大多数非ADTS数据误判。帧长解析的位运算是这个封装过程里最易错的位操作,你对比length的取值和从二进制编辑器里看到的十六进制字节,能把位运算理解得很透。
4.2 音频PES封装:每帧PTS怎么算
音频PTS的计算看上去比视频简单,但细节多。AAC一帧固定包含1024个采样点,不管采样率是多少。因此每帧时长是 1024 / sample_rate 秒。44.1kHz采样率时,一帧约0.02322秒,折合成90kHz单位约2088.98个tick。按整数递增会导致累积误差,我一般会把时间戳累计器和音频帧索引分开,每次用索引乘以浮点值再取整,保证长期录制不偏。
def build_audio_pes(adts_frame: bytes, pts_90k: int) -> bytes: # adts_frame 包含完整的ADTS头,PES payload直接用 payload = adts_frame pes_len = len(payload) + 8 header = bytearray() header += b'\x00\x00\x01' + b'\xC0' # stream_id音频 header += (pes_len >> 8) & 0xFF header += pes_len & 0xFF # 标志位:PTS_DTS_flags = 2,只有PTS header += 0x80 | 0x40 | 0x00 header += 0x05 # header_data_length,仅5字节PTS header.append((0x20 | ((pts_90k >> 29) & 0x07)) & 0xFF) header.append((pts_90k >> 22) & 0xFF) header.append(((pts_90k >> 14) & 0xFE) | 1) header.append((pts_90k >> 7) & 0xFF) header.append(((pts_90k << 1) & 0xFE) | 1) return bytes(header) + payload这里0x80 | 0x40 | 0x00表示PTS存在DTS不存在,因为音频通常不需要B帧解码重排。另一个细节:header_data_length在只有PTS时是5,在PTS和DTS都有时是10,一旦写错解码器会认为PES头截断。这段代码只差分毫,错了就整个音频流全毁。
4.3 音视频PTS如何对齐:起播点的统一基准
音画不同步基本都是在时间戳基准上出了问题。视频起点和音频起点如果各自都从0开始算,一旦两条流中间有缓冲或丢弃,累计误差就会慢慢显现。你可以把起始PTS设为同一个基准值,比如两者都从90000开始。或者以视频第一帧的PTS为基准,音频首帧PTS按视频起点推算。这两个方式都能用,核心是确保“音视频分别从不同NALU/ADTS帧开始时,播放器能识别出相对时序的先后”。
在实际对接时我遇到过这种情况:音频先来了几百毫秒,视频还没来,录出来的TS文件播放时第一个画面要等很久。原因是音频PTS从0、视频PTS从90000开始,播放器为了音画同步会先等视频,即使视频已经就绪。解决方式是把音频PTS拉低或把视频起点降低,保持两者差距在0.5秒以内。这些时间戳策略在直播场景里尤其重要,因为接收端经常直接按PTS决定丢弃或延迟缓冲。
5. 把PES切进TS包:PAT/PMT写入、PCR生成与缓存边界处理
你已经有了视频PES和音频PES,剩下的是切片和复用。写TS包的核心动作一模一样,无论PES怎么变,最终都要切成184字节的payload,按PID和计数递增打上184字节TS头。这章的复杂性全在细节里。
5.1 一个PES切多个TS包的缓冲与续传机制
视频PES通常几百KB,而一个TS包只有188字节,所以一个PES的一定要跨多个TS包。切的时候有个规则:PES的第一个字节必须出现在某个TS包的payload开头,这个TS包的payload_unit_start_indicator = 1;后续TS包跟在这个PES之后继续运数据,直到下一个PES开始。为了保证续传,你需要在内存里维护当前PES的剩余数据,还有当前TS包的剩余空间。常见的翻车点是把PES切在TS包里但下一包开始时又补了新的PES头,导致解码器在PES头解析时看到垃圾数据。
def ts_packet(pid: int, payload: bytes, pusi: bool, cc: int, adaptation=None): # 适配字段控制:1 = payload only, 3 = adaptation + payload afc = 3 if adaptation is not None else 1 pkt = bytearray(188) pkt[0] = 0x47 b1 = (0x40 if pusi else 0) | (pid >> 8) b2 = pid & 0xFF pkt[1] = b1 pkt[2] = b2 pkt[3] = (afc << 4) | (cc & 0x0F) if adaptation is not None: # adaptation field length pkt[4] = len(adaptation) pkt[5:5+len(adaptation)] = adaptation pkt[5+len(adaptation):] = payload else: pkt[4:] = payload return bytes(pkt)这里的入参里最容易被忽略的是cc(continuity counter)。同一个PID的TS包计数必须连续,从0数到15再回绕。每个PID独立计数,不能共用一个计数器。计数不连续播放器会报丢包,计数重复则解码器可能认为B帧重排异常。适配字段参数可传可不传,PCR要放在适配字段里,后面讲PCR时会用到。
5.2 适配字段与PCR:让解码器恢复时钟的“心跳”
PCR不是PTS,它是解码器的时钟恢复参考,告诉解码器某个字节到达时的系统时钟值。TS标准不强制每包都带PCR,但要求至少每100ms出现一次。50fps视频大约5帧一次,25fps视频至少2帧一次。PCR一般放在视频PID的PES起始包里,放在适配字段里。PCR的编码格式比较复杂,6字节长度,包含33bit的PCR_base和9bit的PCR_ext。
构造PCR时很多封装器会直接抄H.264的PTS值,只差一个bit位。这样尽管播放器能播,但在接收端时钟恢复上不够精准。正确方式是维护一个独立累加器:每发送一个TS包,就加上“这个TS包所代表的时间长度”,然后换算成PCR字段写入。如果码率突变,该累积误差会积累成抖动。
def pcr_adaptation(pcr_base: int, pcr_ext: int) -> bytes: # PCR字段应为6字节,需要补足适配字段到字节对齐 pcr = bytearray() pcr.append((pcr_base >> 25) & 0xFF) pcr.append((pcr_base >> 17) & 0xFF) pcr.append((pcr_base >> 9) & 0xFF) pcr.append((pcr_base >> 1) & 0xFF) pcr.append(((pcr_base & 0x01) << 7) | 0x7E | ((pcr_ext >> 8) & 0x01)) pcr.append(pcr_ext & 0xFF) # 适配字段还必须有stuffing字节,直到够长度为止 return bytes(pcr)参数里0x7E是PCR扩展标志位固定内容,stuffing按需填充到适配字段要求的长度。这里没有加长度字段,实际写入时要在适配字段里补到adaptation_field_length一致。
5.3 PAT和PMT的周期重复策略
PAT/PMT频率没有硬性上限,但下限是每100ms一次比较稳妥。常见做法是每100到500ms重复一次,直播场景里我一般取250ms,既保证新播放器快速起播,又不让PSI表占掉太多视频带宽。还有一种策略是只在节目切换或录制开始时反复发PAT/PMT,之后不更新。这种做法如果录制中断或接收端中途加入,会一直黑屏,不推荐。
PMT里如果有多个节目,PAT会列出多个program_number到PMT_PID的映射。单节目TS比较常见,PCR_PID填视频PID。PMT中视频流的stream_type填0x1B,音频填0x0F,还有语言码等描述符不是必需的,但没有描述符的PMT播放器也照样能播放。为减少花屏概率,对H.264加一个H264_registration_descriptor是常见的做法,但也不是必选项。
6. TS封装里的5个高频翻车点:现象、原因与解法
这一章直接给踩坑结论。每个坑我都按“现象 → 原因 → 解决”的顺序写,希望给你的调试省点时间。
6.1 第一个坑:continuity_counter 重复,播放器频繁在TS层面报错
现象:用ffprobe检查输出文件时提示continuity_check_failed,或者播放到某一帧画面直接跳变。
原因:多个PID共用了一个计数器,或者某个PES切包时没对当前PID续计。视频PES被切成10个包,这10个包的连续计数应该是逐步加1,如果中间插入了音频包还用了同一个cc,那视频PID的cc就会跳变,播放器认为发生了丢包。
解决:每个PID单独维护一个计数器变量,视频包发送时视频cc加1,音频包发送时音频cc加1,PAT/PMT的PID单独计数。写复用循环时,把每个PID的cc屯成dict,每次发送后更新,这样最不容易出错。
6.2 第二个坑:SPS/PPS只在文件开头存在,拖动进度条后黑屏
现象:从头播放正常,但seek到中间或从直播中段接入时,VLC永远黑屏。
原因:解码器在进入随机访问点时需要重新初始化上下文,但TS流里没有在这个位置注入SPS/PPS。许多新手只在录制起点注入一次。
解决:强制规则“每个IDR帧的PES包前,一定带上SPS和PPS”。采集模块里如果IDR帧之前没有SPS/PPS,你就把之前缓存的最新的SPS/PPS先放进去。这个操作只在IDR前执行,P/B帧和SEI帧都不必重复。
6.3 第三个坑:时间戳换算写错,视频加速或音画不同步
现象:画面播放速度是正常的1.001倍,或者声音比画面快几百毫秒,且持续时间越长偏移越大。
原因:时间戳换算式用了浮点直接强转整数,但每帧四舍五入误差累积。另一个常见原因是对25fps的帧长3600,59.94fps之类的非整数帧率处理没有用计数器。
解决:维护一个浮点累加器,每次累加后floor取整,不用直接整数相加。另外把所有时间戳计算的基准统一成微秒级再换算,比如先算microseconds / 1000000 * 90000,再强制转成整数,避免连续误差。
6.4 第四个坑:PAT/PMT发送太稀,播放器卡在初始化
现象:新播放器打开网络流后,缓冲5秒才有画面,而且经常首帧就起播失败。
原因:复用器只在开头发了三遍PAT/PMT,后面不再重复。播放器连接后抓不到当前的PAT/PMT,只能反复等待。
解决:每发送大约250ms的TS数据,就强制插入一次PAT和PMT。可以把PAT/PMT的PID也纳入TS包序列,其cc独立计数。若担心PAT/PMT占用数据带宽,就放宽到500ms。这个区间内缓冲的时间直接影响起播延迟。
6.5 第五个坑:AAC ADTS头解析错误,音频全是噪声
现象:视频正常,音频沙沙响或完全静音。
原因:ADTS帧长解析位偏移算错。很多人的解析是从data[2]开始的,但实际帧长跨了4个字节的位段,漏掉第二字节或移位不对都会导致跳帧错位。
解决:用十六进制工具打开一帧音频,把第3字节到第5字节的值拿出来,对照二进制位自己走一遍。把ADTS头的前7个字节打印出来,与参数解析的结果做对比,只要一帧对齐,后续全部对齐。
7. 用ffprobe和VLC做成品验收:验证封装结果是否可信
到这里你可复现的TS封装已经完整了,最后一步是验证。我不可能在没有测试流的情况下给你保证,但这些验证方法能帮助你判断自己的输出是否可行。
7.1 用ffprobe检查流信息与时间戳
ffprobe是FFmpeg套件里的内置工具,它能解析TS容器并显示每个PID对应的流信息。如果你的TS封装正确,你应该能看到两个流:一个h264 (High),一个aac (LC),并且两者的duration和start_time数字都在合理范围内。执行方式:
ffprobe -show_streams -show_format output.ts重点看codec_name是否与你写入的格式一致,start_time是否是小正数或0,duration与源视频是否有较大出入。若duration明显偏大或出现NaN,多半是末尾的时间戳没有收尾。
7.2 VLC播放测试与音画同步目测
VLC是个可靠的验证播放器。播放时按Ctrl+I打开统计信息,观察损失的帧数和音画同步指示。正常时loss帧数应保持0,码率统计与你的输入码率吻合。播放前几秒卡顿不算大问题,但如果连续丢帧说明TS包或时间戳结构有问题。另一个技巧是用VLC的--ts-check-crc参数启动,它能帮你把TS承载层的CRC校验打开,任何一包的比特错误都会放大打印。
7.3 一个可选的最后技巧:对TS文件做同步扫描
自己写一个快速离线检查脚本,确定整个文件里0x47同步字节每188字节出现一次且没有偏移。代码如下,可以在验收最后一步执行。
def validate_ts_sync(filepath: str): with open(filepath, "rb") as f: data = f.read() sync_pos = data.find(b'\x47') if sync_pos == -1: return False count = 0 for i in range(sync_pos, len(data) + 1 - 188, 188): if data[i] != 0x47: print(f"同步字节错位 at offset {i}") return False count += 1 return count > 0这个脚本不能识别时间戳或PID错误,但能帮你排除封装层最基本的字节错位问题。任何一个字节的偏移都会让TS解析器瞬间失步,播放器只剩马赛克和杂音。先跑这个脚本,再跑ffprobe,基本能把项目成功率拉到可发布级别。
做TS封装这一个方向,我踩过的坑基本都源自没吃透PES切包和PAT/PMT的细节。多数时候不是不会封装,而是把一个计数器写错,把一个时间戳位算错,就把整个文件搞废。写代码时尤其注意把每个PID的cc分开管理,把时间戳的累积器与帧索引分开维护。我的习惯是边写打包器边在旁边放一个十六进制编辑器,每一段TS包都盯一下。希望你也能少走这些弯路,希望这些经验能帮到你。
本文还有配套的精品资源,点击获取