ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

H.264视频裸流打包成TS流:PES封装、PAT/PMT与时间戳实战指南

H.264视频裸流打包成TS流:PES封装、PAT/PMT与时间戳实战指南 简介这份资源是一套用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 payloadTS流是由一个个固定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固定是0x0000PMT的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:i188] h parse_ts_header(pkt) if h is None: print(f位置 {i} 处的同步字不合法) break if h[pid] in (0x0000, 0x0010, 0x0011, 0x0100, 0x0101): # 只打印关心的PID print(fpos{i} pid0x{h[pid]:04X} fpusi{h[payload_unit_start]} fafc{h[adaptation_field_control]} fcc{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_id0x000x02section_length取决于内容长度一般0x0D左右取决于流数量计算得出program_number自定义一个节目用1必须等于PAT里声明的节目号PCR_PID无指向视频PID通常放在视频流stream_type无0x1B表示H.2640x0F表示AACelementary_PID无指向视频或音频PID写入顺序也有讲究PAT第一个发出PMT紧随其后随后才是视频和音频的PES包。播放器如果先看到视频PES包但尚未见过PMT会丢帧直到同步到PMT。因此录制头几十个TS包时PAT/PMT的重复频率和首包位置决定了播放器起播延迟。3. 从H.264裸流构建视频PESNALU处理、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:i4] M4: start_codes.append((i, 4)) i 4 elif data[i:i3] M3: start_codes.append((i, 3)) i 3 else: i 1 # 根据起始码位置切NALU nalus [] for idx, (pos, size) in enumerate(start_codes): end start_codes[idx1][0] if idx1 len(start_codes) else len(data) nalu data[possize: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: PTSDTS 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头内保留位为10x40表示原始数据匹配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同步字开始逐帧切AACAAC裸流通常被存储为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:offset2] if (head[0] 0xF6) ! 0xF0: # syncword layer 00 # 0xF6屏蔽MPEG版本和layer位后应为0xF0 return None # 拿到帧长 length ((data[offset3] 0x03) 11) | \ (data[offset4] 3) | \ ((data[offset5] 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:ilength] if len(frame) length: frames.append(frame) i length else: break return frames0xF6这个校验逻辑值得解释ADTS首字节是0xFF第二字节高四位是MPEG版本和layerAAC对应的值是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, adaptationNone): # 适配字段控制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:5len(adaptation)] adaptation pkt[5len(adaptation):] payload else: pkt[4:] payload return bytes(pkt)这里的入参里最容易被忽略的是cccontinuity 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加1PAT/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的帧长360059.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是否是小正数或0duration与源视频是否有较大出入。若duration明显偏大或出现NaN多半是末尾的时间戳没有收尾。7.2 VLC播放测试与音画同步目测VLC是个可靠的验证播放器。播放时按CtrlI打开统计信息观察损失的帧数和音画同步指示。正常时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包都盯一下。希望你也能少走这些弯路希望这些经验能帮到你。本文还有配套的精品资源点击获取
返回列表