ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入TS流:精确解析杜比视界L5元数据的完整实践

深入TS流:精确解析杜比视界L5元数据的完整实践 做播放器性能分析时要在 TS 流里精确抓取杜比视界的 L5 元数据这事看着小众真上手会发现链路特别长188 字节一个的 TS 包、带指针的 PSI section、藏在 HEVC 关键帧附近的 RPU NAL每一层都有各自的坑。最烦的是 L5 这种镜头级 trim 数据块不像 L1 那样固定在头部字段它在 RPU 的 DM data 扩展区里以 TLV 形式出现不按规范逐位解一遍你根本不知道哪个字节才是真正要的东西。这篇文章把完整过程整理出来从 M2TS 文件一路拆到 TS 包、PES、HEVC NAL最后定位并解析出杜比视界的 L5 元数据。中途会说明每一步为什么这么做也会列出我实际踩过的坑比如 PMT 跨包导致 PID 丢失、双层 Profile 7 流里 RPU 出现的位置和单层流不同、ffprobe 能告诉你“有没有杜比视界”却给不了 L5 的细节等等。适合刚接触 TS 流协议、或者想自己写杜比视界分析工具的开发者参考也适合想验证手里片源元数据完整性的视频爱好者。1. L5 元数据是什么为什么要费劲去 TS 流里挖1.1 三个关键词的关系TS 流、杜比视界、L5先说清楚这三个概念之间的关系。TS 流Transport Stream是一种多媒体封装格式最常见于数字电视、卫星广播和蓝光原盘 M2TS 文件。它把视频、音频、字幕、时间戳等内容全部打散成 188 字节的固定长度小包然后按 PIDPacket Identifier区分不同的逻辑通道。杜比视界Dolby Vision是一种 HDR 格式它的核心是一套动态元数据系统用来告诉显示设备“这一帧/这一个镜头应该用什么样的亮度曲线和色彩映射关系去还原”。L5 则是杜比视界 RPUReference Processing Unit元数据里的一个层级专门负责镜头级 trim pass 参数。展开一点说杜比视界的 RPU 元数据分了好几个 LevelLevel 1 是基础动态元数据包含峰值亮度、最小亮度、色域信息等Level 5 则是镜头级的修剪参数它定义了在不同目标显示设备上亮度曲线应该怎么“微调”——默认的话太暗那就给一个正斜率去提亮暗部高光溢出那就降一点功率或偏移。这些 trim 参数直接影响最终画面观感。不少播放器在支持杜比视界时其实是靠 RPU 里的 L1 加 L5 共同作用来还原画面的。L5 缺失或者解析错了画面细节就可能在暗部压缩或者高光裁切上翻车。1.2 为什么选择“TS → PES → ES → NAL → RPU”这条拆解路线L5 藏在 RPU 里RPU 藏在 HEVC 流里HEVC 流又藏在 TS 流里。所以正确路线就是一层层剥开先看懂 TS 包里哪些 PID 是视频流再把同一个 PID 的 payload 拼成 PESPacketized Elementary Stream从 PES 里提取完整的 HEVC 裸流接着在裸流里找到 NAL Unit筛选出杜比视界专用的 RPU NAL最后再解析 RPU 内部的 DM data定位 L5 数据块。有人会问能不能直接在整个 M2TS 文件里搜 L5 的特征字节理论上有时候能蒙中但 L5 不是一个固定字节序列它的字段值是变长的而且 RPU 内部还存在多个扩展块不按结构解析很容易误判。更关键的是TS 流里可能同时存在多个视频 PID还可能有双层的杜比视界BL 基本层 EL 增强层直接全局搜字节会抓到一堆噪声。所以老老实实按协议拆才是可靠的做法。1.3 整体方案工具先行脚本收尾实操上我把它拆成两段第一段先借助现成工具快速抽流第二段用 Python 脚本做精细解析。具体的组合是用 ffprobe 确认 M2TS 文件里视频流的 PID 和杜比视界 profile用 ffmpeg 直接把视频轨道 copy 成 HEVC 裸流文件用 dovi_tool 提取并解析 RPU先拿到 JSON 形式的元数据概览最后用自写的 Python 脚本在 TS 包层面验证 PID、在裸流层面定位 NAL type 62、在 RPU 里扫描 L5 数据块。这个组合看起来绕但其实是最稳的。全自动解析 TS 流有时会因为流不标准而翻车而你手上有工具的输出做交叉验证出问题时能很快定位是“TS 拆包”导致的还是“RPU 解析”导致的。2. TS 流结构拆解从 188 字节小包挖出视频 PID2.1 TS 包基础0x47 同步头与 PID 筛选TS 流最小的单位是包每个包固定 188 字节。第一个字节永远是 0x47也就是同步字节。紧跟其后的几个字节里包含 PIDPID 一共 13 位占第二个字节的低 5 位和第三个字节的 8 位。理解 PID 是拆解 TS 流的钥匙PID 0 是 PATProgram Association TablePAT 里会列出这个流里有哪些节目以及每个节目的 PMT PID 是多少PMTProgram Map Table里又列出这个节目包含哪些流以及每个流的 PID 和类型。实际操作时一般不直接猜 PID而是先扫 PAT再根据 PAT 里的 PMT PID 去抓 PMT最后从 PMT 里找到视频流 PID。这样做的最大好处是兼容性不同文件、不同编码工具的 PID 分配完全是随机的写死一个 PID 换个文件就废了。下面这段 Python 代码可以遍历 TS 包并解析 PAT/PMTTS_PACKET_LEN 188 TS_SYNC 0x47 def ts_packets(path): with open(path, rb) as f: while True: pkt f.read(TS_PACKET_LEN) if not pkt or len(pkt) ! TS_PACKET_LEN: break if pkt[0] ! TS_SYNC: continue yield pkt def packet_pid(pkt): return ((pkt[1] 0x1F) 8) | pkt[2] def packet_payload(pkt): # 跳过4字节TS头这里先忽略adaptation field的情况 return pkt[4:]这段代码是最简版。真实文件里 TS 包可能存在 adaptation field调整字段包头的第四个字节里会用标志位告诉你 payload 是否有效以及 payload 从第几字节开始。完整版要处理这些标志位不然后面拼出来的数据全是错的。我在实际开发里是先做包级校验再判断 adaptation field最后才拼 payload顺序反了很容易出现“第一段解析正常、第二段就乱码”的情况。2.2 PAT 和 PMT节目映射关系的“电话本”PAT 和 PMT 都遵循 PSIProgram Specific Information格式以 section 为单位传输。一个 section 不是必须塞在一个 TS 包里它可能跨包所以完整实现需要做 section 拼接。但对于大多数 M2TS 文件PAT/PMT section 通常不大基本一个包就能装完。我写的解析器先支持单包 section遇到异常再用一个状态机去拼续包。解析 PAT 的逻辑是找到 PID 0 的 TS 包取出 payloadpayload 的第一个字节是 pointer_field通常为 0表示 section 从 payload[1] 开始。section 头里 table_id 为 0x00 的是 PAT。PAT 主体以 4 字节为一条记录前两个字节是 program_number后两个字节里低 13 位是 PMT PID。伪代码如下def parse_pat_from_payload(payload): if payload[0] ! 0: # 极少数情况需要先跳过pointer_field return [] # 简单模式假定section单包完整跳过section_length等 entries [] i 1 3 1 # pointer table_id section_length 版本等 while i 188: if payload[i] 0xFF: break program_number (payload[i] 8) | payload[i1] pmt_pid ((payload[i2] 0x1F) 8) | payload[i3] if pmt_pid ! 0x1FFF: entries.append((program_number, pmt_pid)) i 4 return entries拿到 PMT PID 后再去抓对应 TS 包解析 PMT。PMT 里关键的字段是 stream_type 和 elementary_PID。stream_type 为 0x24 时表示 HEVC 视频流其他类型包括 0x81 的 AC3、0x06 的私有数据等。我的代码里输出所有 stream_type 和 PID方便人工确认哪个 PID 是杜比视界视频轨道。2.3 从视频 PID 到 PES把零散 TS payload 拼成完整 ES 流找到视频 PID 之后接下来要把这个 PID 的所有 TS 包 payload 按顺序拼接起来得到 PES 流。PES 流是“包”的集合每个 PES 包都有一个包头包含 PES 起始码00 00 01、stream_id、PES_packet_length以及可选的 PTS/DTS 和扩展信息。PES 包有两种长度情况一是明确给出 PES_packet_length那就按长度截取二是某些流比如 PS 流会持续到下一个起始码。TS 流里通常用第一种所以解析起来就是找到 00 00 01读取 header 长度跳过可选字段然后后面就是完整的、分片承载在 TS 包里的 ES 数据。这里不需要把 PTS/DTS 全部解出来只需要关心 payload 部分因为它就是 HEVC 的 Annex B 字节流。这个字节流里全部是 NAL Unit用起始码分隔。正常来说从视频 PID 拼出来的 HEVC 流已经以 00 00 01 或 00 00 00 01 开头可以直接作为.hevc文件喂给 ffmpeg 或 dovi_tool。这也是我建议先用 ffmpeg 抽流的原因它能帮你处理掉 TS 层所有麻烦给你一份干净的 HEVC 裸流去分析 NAL。2.4 一个小坑PAT/PMT section 并非永远单包完整我一开始想当然地认为 PAT/PMT 都是小 section一个 TS 包都能装下。结果遇到一个录制源切的 TS 文件PAT section 被拆成了三个 TS 包中间还夹了空包。没有拼接逻辑的代码直接退出循环PMT PID 永远找不到视频流后面全废。解决思路也不复杂遇到 PID 是 0 的包把 payload 里的字节追加到一个缓冲区并且留意 section_syntax_indicators、section_length 来确认当前 section 何时结束。等 section 长度够了再整体解析。做完这个之后PAT/PMT 解析基本就稳了。3. 在 HEVC 裸流里定位 RPU NAL3.1 Annex B 格式与 NAL Unit 起始码HEVC 裸流通常有两种格式Annex B 和 MP4 格式。TS 流里的 HEVC 必然是 Annex B也就是用起始码分隔 NAL Unit。起始码有两种3 字节的 00 00 01 和 4 字节的 00 00 00 01。遇到 4 字节起始码时多出来的 00 一般被认为是前一个 NAL 的 trailing byte。解析 NAL Unit 的步骤很简单扫描整个 HEVC 字节流找到起始码之后跳过起始码接下来读取 NAL Unit Header。H.265 的 NAL Unit Header 是 2 字节其中前 6 位表示 nal_unit_type。类型范围很多31 表示 IDR_W_RADL32 表示 IDR_N_LP39/40 表示前缀/后缀 SEI而杜比视界 RPU 用的是类型 62。很多人容易在这里卡住以为杜比视界 RPU 是一个 SEI 消息于是拼命在 nal_unit_type 39/40 里找。实际上在 HEVC 流里杜比视界的 RPU 往往以独立 NAL Unit 的形式出现nal_unit_type 就是 62而不是被包在 SEI 里。这个认知偏差会浪费不少调试时间。3.2 提取 NAL type 62 的 Python 实现下面这段代码从 HEVC 裸流里遍历所有 NAL Unit并把类型为 62 的完整 NAL payload 保存下来def extract_rpu_nalus(hevc_path): with open(hevc_path, rb) as f: data f.read() nalus [] i 0 n len(data) while i n - 3: if data[i] 0 and data[i1] 0 and data[i2] 1: # 找到起始码 start i 3 if data[start] 0: # 4字节起始码的情况 00 00 00 01 start 1 end start while end n - 3: if data[end] 0 and data[end1] 0 and (data[end2] 1 or (data[end2] 0 and data[end3] 1)): break end 1 nalu data[start:end] if len(nalu) 2: i end continue header int.from_bytes(nalu[0:2], big) nal_type (header 9) 0x3F if nal_type 62: nalus.append(nalu) i end else: i 1 return nalus这段代码里比较关键的判断是(header 9) 0x3F。如果把第一个字节当成一个 8 位整数nal_unit_type 其实是(header 0x7E) 1效果一样。写成 9是因为 int.from_bytes(nalu[0:2], big) 能同时获得 16 位高位 15 位是 forbidden_zero_bit14-9 位是 nal_unit_type。两个写法等价选哪个取决于个人习惯。提取出来的 RPU NAL payload 才是真正需要分析的杜比视界数据。一个 4K 蓝光原盘里这样的 NAL 数量非常多每个镜头的 L5 数据可能都会有变化。为了减少噪声后续分析时最好结合关键帧和 shot boundary 信息只分析镜头切换处的 RPU。3.3 为什么不应该直接拿 ffprobe 对付 L5ffprobe 确实能告诉我们流里“有没有”杜比视界比如ffprobe -show_streams xxx.m2ts会在 side_data 里显示 DOVI configuration record给出 profile 7、level 6 这类信息。但它不会把 RPU 里每个镜头级的 L5 trim 参数列出来因为那是需要在解析时读取大量动态元数据的过程不是流级别的静态信息。所以我的建议是ffprobe 用来粗筛真正读 L5 得用 dovi_tool 或者自己写解析器。dovi_tool 的输出是 JSON可以直接看到 level5 数据段是否存在以及它内部有哪些字段。这个方法适合快速了解片源情况。4. 解析 RPU 内部读取 L5 数据块4.1 RPU 的核心结构变长字段 多层扩展杜比视界 RPU 的 payload 结构相当“拧巴”它允许非常多可选字段而且不同 profile、不同 level 的位宽还不一样。直接硬编码偏移去解析是一个非常糟糕的思路因为你换一个片子、换一个 profile偏移就可能全错。RPU payload 大致分几块最前面是 rpu_nal_prefix 和一些头部字段比如 vdr_rpu_profile、vdr_rpu_level 等再往后是各种扩展块extension blocks再往后是 vdr_dm_data_payload这里面才是动态元数据。而 L5 就藏在 vdr_dm_data_payload 对应的 DM metadata level 列表里。在实际二进制布局里DM metadata 的多个 level 是以带类型和长度的 TLV 形式连续存放的。也就是说每个数据块可能先是类型或者由上下文决定类型再是长度然后是数据。Level 1 通常是第一个出现的基础元数据Level 5 一般紧随其后或者按固定顺序排列。解析时不要先猜偏移而是按“读到长度 → 跳过数据块 → 判断类型”的方式顺序遍历直到遇到目标 type。4.2 TLV 扫描法不硬编码偏移按长度跳数据块我自己写了解析器之后回头复盘发现最实用的工具不是二进制编辑器而是一个遍历 TLV 块的通用函数。大概逻辑如下def walk_tlv(data, start): # 假定前若干字节是头部传入的是DM data payload起始位置 i start levels {} while i len(data) - 3: # 类型和长度的字节位布局因RPU头而异 # 这里按常见实现简化先判断level标记 if data[i] in (1, 5): level_type data[i] length int.from_bytes(data[i1:i3], big) levels[level_type] data[i:i3length] i 3 length else: # 未知字节逐字节前移等下一条level标记 i 1 return levels这段代码不是完整实现它代表的是“扫描 跳跃”的思路不试图从 RPU 第一字节开始严格解码每个字段而是从 DM data 区域开始找类型标记拿到长度就跳过一次性把 level1、level5 等数据块都切出来。切出来之后的东西可以再丢给具体解析器或者直接 hexdump 对照。实际操作里我更推荐一个交叉验证流程先用 dovi_tool 的 JSON 输出看到 level5 存在再用自己写的 TLV 扫描把它从二进制里精确裁出来两个结果做一次比对。这样既能学到 RPU 的藏匿位置又不至于为了一个变长字段的位宽解析折腾一整天。4.3 实战示例L5 数据块到底长什么样我截取过一段真实片源的 RPUdovi_tool 解析出来的 JSON 里 level5 大致包含这样一些字段trim_slope、trim_offset、trim_power以及针对不同色域/不同亮度的多组调整值。这些值的组合描述了镜头里从暗部到高光的映射曲线在基础 L1 之上该做怎样的二次修正。举个直觉化的例子一个以日落逆光为主的镜头如果目标显示器峰值亮度不够L5 会把高光区 trim_power 调得委婉一些防止大面积过曝。如果你拿到 RPU 之后用 xxd 去看 level5 的原始字节会看到它不像 SEI 那样有大段可读 ASCII基本全是二进制数值一组组排得很密。正因为它不直观所以解析时的注释要写清楚每个字段的位宽和单位。否则过两个星期再看鬼知道那一串 0xF7 表示的是什么斜率。4.4 用 dovi_tool 做快速验证如果觉得手写 TLV 扫描不放心可以先用 dovi_tool 把 RPU 转成 JSONdovi_tool extract-rpu --input video.hevc -o rpu.bin dovi_tool json --input rpu.bin -o rpu.json打开 JSON 后重点看里面有没有 level5 字段以及这个字段的数据长度。如果 level5 为空或者长度为 0说明这个片源虽然表面上有杜比视界但实际并没有提供镜头级 trim 信息播放时只能用 L1 做基础映射。这种情况在部分低码率 web-dl 里并不少见。4.5 位对齐RPU 的字段经常不是整字节边界解析 RPU 时最容易犯的错是“直接按字节读字段”。杜比视界的元数据是从 bit 级别的位流拼出来的很多字段跨字节。比如某个标识位只占 1 bit下个字段占 5 bit再下个字段可能又跨了 2 个字节。如果你不做一个 bit reader强行用data[0]这种字节索引去读读出来的值永远是错的而且错得莫名其妙。解决方案是写一个 BitReader维护当前字节位置和 bit 偏移支持读 n 位、读 ue(v)、读有符号值等。具体到 L5 解析我在实际用的脚本里只读几个关键字段镜头切换标记、trim_slope、trim_offset、trim_power然后就输出结果。剩下的字段按长度跳过不做反推。这样做的好处是即使未知字段的位宽判断错了你也能通过长度约束把它顺手跳过不会导致整个解析器崩溃。5. 实操中的典型坑和排查技巧5.1 多个视频 PID 时怎么选对轨道有些 M2TS 文件会有两个视频轨比如正片和画中画或者杜比视界双层里 BL 和 EL 拆成两个 PID。PMT 里能看到多个 stream_type 为 0x24 的条目这时候不能随便选第一个。我的做法是先用 ffprobe 列出所有视频流索引再看哪条流在 side_data 里带 DOVI configuration record。带 DOVI 记录的那条就是要找的主视频轨。如果 ffprobe 输出里本身就有dovi_profile7就说明这条流已经包含了 BL/EL/RPU直接抽它。实在不行就两个 PID 都抽一遍分别跑 dovi_tool哪个能提取出 rpu.bin 就选哪个。5.2 双层 Profile 7 和单层 Profile 5/8 的差异UHD 蓝光原盘常见的杜比视界是 Profile 7它由 HDR10 兼容的 BL 层 Dolby Vision 的 EL 层 RPU 组成三层数据通常复用同一个 PID 或相邻 PID播放器需要把 BL 和 EL 做融合再用 RPU 做动态映射。而网飞等流媒体常见的 Profile 5/8通常不需要 EL 层视频流里主要是 IPT 色域信号RPU 照样存在但 L5 的存在情况不一定。做 TS 流解析时不需要真的去融合 BL/EL因为我们要的是截取 RPU不是完整播放。但理解 profile 区别有助于排查“为什么这个文件里没有 L5”的问题。Profile 7 的 RPU 一般比较完整L1/L5 都有Profile 5 有时会省掉一些非必要的扩展数据块如果你的解析器写死了“必须解析到 L5”遇到这种片源就会报错。最好是解析器能容忍 L5 缺失输出一个明确警告而不是直接崩溃。5.3 ffprobe 能确认杜比视界的存在但它拿不到 L5再强调一次这个分工。ffprobe 的强项是流级别信息比如编码格式、分辨率、帧率、像素格式以及是否存在 DOVI configuration record。但 L5 属于动态元数据需要扫描整段视频流、针对每个镜头输出并验证。ffprobe 不适合干这个活别在这一步浪费太多时间。5.4 TS 包有效载荷不是连续的adaptation field 容易吃人在拼接 ES 流时我踩过最大的坑就是忘了每个 TS 包头里的 adaptation field 控制字节。有些复用器在 PTS 跳动时会在 TS 包里塞 adaptation field如果直接固定从pkt[4]开始追加 payload得到的 ES 流会因为多出几个位置偏移字节而彻底错乱。正确做法是在 TS 包第四个字节里看 PUSIpayload_unit_start_indicator和 adaptation_field_control 两个标志。前者告诉当前 TS 包是否是某个 PES/PSI 分片的起始包后者告诉 payload 是否存在。存在 adaptation field 时要跳过 adaptability_length 指定的长度再做拼接。这个逻辑加上去后基本所有标准 TS 流都能正确处理。5.5 NAL 起始码的 3 字节和 4 字节HEVC 里 NAL 起始码可能是 3 字节也可能是 4 字节。4 字节的情况往往出现在流或文件起始处为了对齐 32 位边界。解析时不能只认 3 字节的 00 00 01否则会把 00 00 00 01 解析成 00 00 00 01导致 NAL 内容多出一个字节、类型判断全错。其实还有更隐蔽的坑在某些 TS 原始流里PES 包的 payload 开头前 4 字节可能是 00 00 00 01但后续 NAL 之间是 00 00 01 分隔。最稳妥的做法是扫描时同时判断两种模式并且从 i 位置再往前看一位如果前一位也是 0那就是四字节起始码。这类细节写清楚注释就没事了。6. 拿到 L5 元数据之后能做什么6.1 给播放器开发提供“亮度曲线调试”依据播放器要做杜比视界映射不能只依赖“这是杜比视界”这个标签。真正决定每一帧怎么映射的就是 L1 加 L5。L1 给出全局亮度范围L5 给出镜头层面的 trim 修正。把 L5 数据打印出来对照实际播放画面的暗部细节可以快速定位播放器映射算法是否需要调整。比如发现某个镜头暗部死黑查看 L5 的 trim_slope 和 trim_offset基本能看出算法有没有正确读取参数。6.2 片源质量验证与排错如果你在做影视资源管理、转码压片或者画质对比L5 元数据是否完整是判断一个片源“算不算真杜比视界”的重要依据。有的片源表面标记杜比视界元数据里却只有 L1没有 L5那么播放器的观感可能更接近 HDR10 的静态映射。自己写个脚本批量扫描多个文件把“有 L5 / 无 L5”分门别类列出来就是一个极其实用的验证工具。6.3 结合 dovi_tool 进行元数据注入或转换dovi_tool 不只是能提取它还能把 RPU 合并或者转换格式。比如你在分析一个双层原盘后想把它的动态元数据应用到一个重编码单层流上流程一般是从原盘的 RPU 里解析出 L1/L5然后用工具注入到目标 HEVC 流的对应位置。这里对 L5 的理解就直接影响重编码后动态映射的质量如果代码里漏掉 L5 数据块重编码片源在动态表现力上会明显下降。6.4 做更细的“镜头边界”统计L5 数据块通常伴随镜头切换信息出现在关键帧前后。如果你解析多个镜头的 L5并记录它们的变化时间点就能自动估算一个片源里大致有多少个镜头。这个玩法可以用来做场景分析也可以用来验证剪辑的镜头节奏。不过要注意不是每个镜头都必然有 L5 变化有些导演会让一整段连续镜头共用一组 trim 参数这时 L5 会保持相同。我在实际项目上的体会是解析杜比视界的 L5 元数据最难的不是“读字段”而是“找位置”。一旦你把 TS 层面和 NAL 层面理顺RPU 内部那点 TLV 扫描反而是最不费时间的部分。最后分享一个小技巧当你怀疑解析器结果不对时别拿编码器生成的样本文件试错直接抓一部包含多个明显明暗变化的电影原盘比如夜景和逆光多的片段用它的几个关键帧 RPU 去验证 L5 的变化这样很快就能发现是自己解析错了还是元数据本身就缺失。
返回列表