数据与直播画面“神同步”——SEI(补充增强信息)

1. 前言

我在过去的一年多中的大部分时间都在和webrtc直播打交道,这一年中,有不少需求都要求dom或canvas的渲染需要和直播画面有着帧级的同步渲染。这个需求抽象出来可以用一句话概括:如何将和每一帧画面强相关的业务数据与直播帧进行同步传输和同步解析?

无论是还是实现直播过程中的帧级实时渲染(比如实时给直播者的轮廓画上一个框)、还是实现。传统的HTTP或WebSocket信令都因其固有的网络延迟和时序不确定性而无法实现帧级的信息同步和渲染。这篇文章里,我想和大家聊聊一个很巧妙的方案------SEI(补充增强信息)。我将介绍SEI:一种内嵌于视频码流、与视频帧强同步的元数据技术。通过本文,你将了解SEI是什么?为何要用它?怎么用?局限性是啥?这篇文章能帮你做出体验丝滑的互动应用。


2. 引言:当数据需要与画面"神同步"

2.1 需求场景

我将举几个我遇到的需求场景

2.1.1 场景一: 物联网边缘计算结果的无延迟显示

在我们的应用中,物联网设备不再仅仅是一个简单的摄像头,而是一个具备边缘计算(Edge AI)能力的智能终端 。设备内部署了轻量级的AI视觉模型,能够对捕捉到的每一帧视频画面进行实时的目标检测(Object Detection) 。当设备识别出我们关心的目标物体(例如一个人、一辆车或一个特定的工业零件)后,AI模型会立刻输出该物体在当前画面中的精准位置,即边界框(Bounding Box)信息,通常包括矩形框的左上角坐标(x, y)以及它的宽度和高度。

为了将这一动态的识别结果与视频画面无延迟地同步给远端用户,我采用了SEI方案:

  1. 设备会将该边界框数据作为SEI元数据,实时地注入到当前处理的视频帧之中。
  2. 远端的客户端在播放直播流时,只需从每一帧中解析出这条SEI信息,就能在视频画面的相应位置实时、精准地绘制出跟踪框。 这样,即使用户网络有抖动,看到的跟踪框也永远不会与目标物体发生"脱节"或"漂移",完美实现了对动态目标的视觉增强和信息提示。

2.1.2 场景二:基于实时位姿数据同步的AR渲染直播方案

很多物联网设备都可以描述自己的姿态,包括地理空间坐标(经纬度、高度)和设备姿态角(偏航角 yaw、俯仰角 pitch、翻滚角 roll)。这些数据通过SEI的机制与物联网设备采集的视频帧同步。用户的终端如果可以实时获取到设备的实时姿态信息,就可以结合3D地图数据等绘制出车道线、红绿灯等渲染图像,这些AR图像需要"贴"在摄像头传递的实时码流上,且渲染的图像内容需要和该时刻的视频帧保持帧级的同步。

设备的姿态信息需要与直播码流无延迟地同步给客户端。我采用了SEI方案:

  1. 设备会将当前的姿态信息写入最近的直播帧中。
  2. 客户端观看直播时实时收到SEI数据,将实时姿态信息送入AR渲染模块进行渲染。

这样,用户看到的AR效果就是基于当前视频帧时刻的姿态信息进行渲染的,非常的"贴合"和"真实"。

2.1.3 场景三:互联网的交互式直播活动(业界常见场景)

一场火爆的在线直播。主播正在讲解一个关键知识点,并在白板上写下了一道题目,同时口头宣布:"请看题!3, 2, 1,开始作答!" 几乎在同一瞬间,用户的手机屏幕需要弹出了对应的答题卡,并看到实时排名滚动。整个过程如行云流水,没有任何延迟感。

2.2 传统方案的局限

作为开发者,要实现以上需求场景,我们首先想到的可能是传统的信令方案:

  • HTTP API轮询:客户端定时向服务器请求状态更新。这种方式延迟高、实时性差,服务器压力也大,显然无法满足"瞬时"的要求。
  • WebSocket 或 WebRTC DataChannel:这两种是实时通信的利器,无疑比HTTP要好得多。服务器可以在特定时间点,通过这些长连接通道向所有客户端广播指令。

但即使使用了WebSocket 或 WebRTC DataChannel,依旧无法满足我们的需求。因为一个指令从服务器发出,经过互联网到达千里之外的用户,会经历不可预测的网络路由和延迟(Jitter)。张三的指令可能比李四的早到500毫秒。更关键的是,这条"数据指令"和"音视频流"是两条独立的网络路径,它们的延迟是完全不相关的。

这就导致了一个棘手的"时序不一致"问题:用户A可能在主播手势出现前就看到了答题卡,而用户B则是在主播都开始讲解答案了才姗姗来迟地收到题目。这种体验上的"割裂感"和不公平性,在要求严苛的场景下是不可接受的。我们需要一种方法,将指令"锁"在视频画面上,一起发送出去。

1.3 主角登场:SEI

为了解决这个"神同步"的难题,我们需要请出今天的主角------SEI (Supplemental Enhancement Information)

如果把视频码流比作一趟高速列车,那么视频帧本身就是满载乘客的车厢。而SEI,则是一张张夹在车厢门缝里、随车厢一同到达的便签或信件。它本身不是乘客(不影响视频画面),但它携带了至关重要的信息,并且确保在某个车厢(某一视频帧)到达时,这张便签也一定同时到达。

SEI是一种被直接嵌入视频编码层内部的元数据。它搭乘着视频流的"顺风车",与视频帧共享同一个RTP传输通道,享受着WebRTC为保证音视频低延迟和同步所做的一切优化。因此,它天然地解决了数据与视频画面的同步问题。


3. SEI 的背景知识补充 ------ 现代视频流剖析

在咱们前端看来,一个视频流就是 <video> 标签一放就完事了。但如果我们用放大镜去看它在网络上传输的样子,它其实是一串二进制数据包。为了搞明白 SEI 是怎么'塞'进去的,我们就得当一次侦探,看看 H.264 这些视频编码格式给数据包动了什么'手脚'。

首先我们需要建立对现代视频编码和传输结构的基本认知。当前主流的视频编码标准,如 H.264 (AVC) 和 H.265 (HEVC),采用了一种分层设计,该设计精妙地将视频内容的压缩表示与网络传输的需求解耦。本章将详细剖析这一结构,为后续探讨 SEI 的角色和功能奠定坚实的基础。

3.1 视频编码的"关注点分离"原则

想象一下,视频编码标准的设计者面临两个截然不同但又同等重要的问题:

  1. 内容表示:如何用最少的数据量,无损或有损地描述一幅极其复杂的画面?
  2. 内容传输:如何将这些描述了视频的数据,安全、可靠、高效地从一个地方送到另一个地方?

H.264 (AVC) 和 H.265 (HEVC) 标准的卓越之处在于,它没有将这两个问题混为一谈,而是通过一个分层架构,将它们彻底分开 。这就是视频编码层(VCL)网络抽象层(NAL) 的由来。

你可以把 VCL 理解成'画画'的,它只关心怎么把图像内容(比如你的人像)用最少的颜料画出来。而 NAL 则是'打包'的,它把画好的画(VCL数据)放进一个标准化的快递盒(NALU)里,贴上标签(NAL Header)告诉别人'这是画的一部分'、'这是说明书'等等。而我们的 SEI,就是一张被塞进快递盒里的'特别说明'。"

3.1.1 视频编码层(VCL):图像的本质

视频编码层(Video Coding Layer, VCL)是视频编解码器的核心,其唯一目标是以尽可能高的效率来表示视频画面的像素信息 。VCL 的输出是经过一系列复杂算法处理后的数据,这些算法包括基于块的运动补偿预测、变换、量化和熵编码等 。最终生成的 VCL 数据被组织成一系列的"片"(Slices,例如 I 帧、P 帧、B 帧的编码数据),每一片都包含了视频图像某个区域的编码表示。

3.1.2 网络抽象层(NAL):为传输而生的视频封装

为了解决 VCL 数据与多样化网络环境的适配问题,H.264/HEVC 标准引入了 网络抽象层(Network Abstraction Layer, NAL) 。NAL 的主要目标是提供一种"网络友好"的视频表示方式,将 VCL 产生的编码数据格式化为一系列逻辑数据包,即 NAL 单元(NAL Units, NALU)。 NAL unit就是NAL的基本语法。以H.264为例,原始码流就是由一个一个的NALU组成的,其中每个NALU是由NAL header和来自VCL的原始数据字节流(RBSP)组成,如下图所示:

这种设计使得视频数据能够灵活地适配各种传输协议和存储格式,无论是用于实时通信的 RTP/IP 协议、用于文件存储的 MP4 容器,还是用于广播的 MPEG-2 系统 。每个 NAL 单元都以一个标准化的头部开始,该头部用以标识单元内部所含数据的类型,从而指导解码器或传输系统如何处理它 。

NAL 的存在是像 SEI 这类技术得以实现的架构基础。它将核心的视频像素数据与网络传输的复杂性隔离开来,允许在视频流中插入辅助数据包而不会干扰核心的解码流程。

3.1.3 VCL 与非 VCL NAL 单元:图像与元数据(比如SEI)的关键区别

NAL 架构中最核心的一个概念,是将 NAL 单元划分为两个基本类别:VCL NAL 单元和非 VCL NAL 单元 。

  • VCL NAL 单元:这类单元承载着构成可视化图像的实际编码数据,即视频帧的片(Slices, 例如 I 帧、P 帧、B 帧的编码数据)。它们是视频流中"所见即所得"的部分。
  • 非 VCL NAL 单元:这类单元不包含任何像素信息,而是承载着对解码过程至关重要或能增强播放体验的辅助信息,即元数据和控制信令 。

SEI就是上面提到的非 VCL NAL 单元

这种明确的分离是 SEI 之所以"补充"和"增强"的根本原因。一个解码器在获取了必要的参数后,理论上仅通过处理 VCL NAL 单元就能解码出可观看的视频。所有非 VCL NAL 单元提供的都是支持性信息,这确保了系统的向后兼容性和鲁棒性(因为各种解码器可以安全地忽略它无法理解的非 VCL 数据,而不会导致解码失败从而导致无法播放视频) 。


4. SEI 是什么?

4.1 定义

SEI,全称为补充增强信息(Supplemental Enhancement Information),是在 H.264/AVC 和 H.265/HEVC 视频编码标准中正式定义的一种数据结构 。其设计目标是承载那些对于核心解码过程并非必需,但可用于增强解码后处理、显示或其他特定目的的附加信息 。

4.2 SEI 的核心特性

理解SEI在NALU家族中的位置后,它的三大核心特性就显而易见了:

  • 补充性 (Supplemental) :SEI携带的信息对视频解码的核心流程来说是"可有可无"的。一个解码器如果"不认识"或选择忽略某个SEI NALU,它依然能完美地解码并播放视频。这提供了极好的向前和向后兼容性。
  • 非必需性 (Non-essential) :与SPS/PPS这些虽然也是Non-VCL但却是解码必需的参数集不同,SEI是完全可选的。一个视频码流可以不包含任何SEI信息。
  • 随帧传输 (Frame-Accurate) :这是SEI最具价值的特性。SEI NALU与VCL NALU(视频帧)一起被打包、排序、并通过RTP协议传输。这意味着,当接收端从RTP流中解析出某一帧画面数据时,与之关联的SEI数据就在"隔壁"。它们在时间戳上是严格对齐的。

为了更形象地理解,我们再次使用那个"快递包裹"的比喻:

  • 视频码流:一个巨大的快递包裹。
  • VCL NALU(视频帧) :包裹里的主要商品(比如一部手机)。
  • SEI NALU:贴在手机包装盒上的**"内件清单"或"发货备注"**。这张清单本身不是手机,你扔掉它,手机照样能用。但它包含了"手机颜色:星空黑"、"内含配件:充电器、耳机"等重要附加信息。最关键的是,这张清单一定是和这部手机一起到达你手中的。

SEI的"超能力"就源于上述的"随帧传输"特性,这使得它成为了实现数据-画面强同步的标准解法。

4.3 SEI的核心价值:精确的时间同步性

当我们说"同步",我们究竟在追求什么?我们追求的是在事件的"因"和"果"之间建立确定性的时序关系。

举一个短视频app直播的例子:

  • "因" :主播在视频画面中做出了某个动作。
  • "果" :用户终端需要展示一个对应的UI元素。

如果使用独立的信令通道,从"因"的发生(编码端捕捉到画面)到"果"的呈现(解码端渲染画面+信令触发UI),中间隔着两条延迟特性完全不同的网络路径,时序关系是模糊的、不确定的。

而SEI,将"果"的指令(数据)嵌入到了"因"(视频帧)的载体中。它们从编码端开始就"生死相依",共同经历网络的风风雨雨。当解码端收到视频帧时,指令也必然同时到达。时序关系从"模糊"变成了"确定",这就是SEI的核心价值所在。

4.3 视频码流中写入SEI的不同方式

由于场景太多,每个场景都对应了不同的SEI注入方式,代码实现的层面暂时无法详细展开,本节按照应用场景分类讨论,。

4.3.1 物联网场景

注入站点 物联网设备端 (Source) 边缘计算节点 (Edge) 云端媒体服务器 (Cloud)
同步精度 最高 (物理同步) 较高 (局部网络延迟) 最低 (端到云延迟)
开发难度 (嵌入式/C++) (流媒体处理) (Web/API调用)
系统灵活性 (固件更新) (边缘服务部署) (云服务即时更新)
典型数据 传感器数据、设备状态 边缘分析结果、局部统计 业务事件、用户交互、第三方数据
决策核心 追求极致同步 数据产生在边缘计算节点 感觉不太建议了,不如直接websocket
4.3.1.1 源头注入 (在物联网设备端)

这是将数据与画面做"像素级"同步的终极方式。

  • 注入模块: 运行在物联网设备(如网络摄像头、无人机、工业相机)内部的嵌入式应用程序。

  • 注入时机/方式:

    • 需要设备厂商提供底层的媒体处理的能力。
    • 设备上的应用程序通过C/C++等语言,在相机模块等产生。
    • 在视频帧被送入H.264/H.265编码器的前一刻,或者作为编码器的一个参数,将需要写入的数据(如传感器读数)构建成SEI NALU,然后"塞"入到待编码的视频数据中。
  • 注入的数据类型: 与设备硬件强相关的、对实时性要求极致的数据。

    • 无人机: 实时的GPS坐标、海拔高度、飞行姿态(横滚、俯仰角)、云台角度。
    • 工业相机: 触发拍照时的精确微秒级时间戳、产线上工件的序列号、当时的光照传感器读数。
    • 智能摄像头: 内置陀螺仪的抖动数据、电池电量、设备温度。
  • 场景与权衡:

    • 优点:

      • 极致同步: 数据与画面在"物理世界"的同一瞬间被绑定,同步精度最高,无任何网络延迟干扰。
      • 数据原始性: 保证了数据的源头真实性。
    • 缺点:

      • 开发难度高: 需要嵌入式开发能力和硬件厂商的深度支持,远离Web技术栈。
      • 设备负载: 增加了资源本就紧张的物联网设备的计算负担。
      • 灵活性差: 注入逻辑固化在设备固件中,任何修改都需要进行OTA升级。
4.3.1.2 中途注入 (比如在边缘计算节点)
  • 注入模块: 部署在物联网设备旁边的边缘计算网关、NVR(网络录像机)或边缘服务器。

  • 注入时机/方式:

    • 物联网设备将原始视频流(如RTSP流)推送到边缘节点。
    • 边缘节点拥有更强的计算能力(可能有NPU/GPU),它会对视频流进行实时分析。
    • 分析完成后,边缘节点需要对码流进行"手术":解析出NALU,将新生成的SEI NALU插入到视频帧之间,然后重新封装码流再向上游(云端)或周围(其他本地设备)转发。这通常使用GStreamer、FFmpeg或高性能的流媒体处理程序来完成。
  • 注入的数据类型:

    • 经过AI模型或其他复杂逻辑处理后的分析结果

    • 示例:

      • 智慧安防: 边缘节点运行人脸识别或车牌识别算法,将识别出的人名或车牌号作为SEI写入视频流。
      • 工业质检: 边缘节点运行缺陷检测AI模型,一旦发现产品瑕疵,立即将缺陷类型、位置坐标、置信度等信息作为SEI写入。
      • 智慧零售: 边缘节点分析客流,将"区域A客流+5"这样的统计数据写入店铺主摄像头的码流中。
  • 场景与权衡:

    • 优点:

      • 平衡点: 既为前端设备减负,又比云端处理延迟低得多,实现了局部实时智能。
      • 功能强大: 可以在边缘运行复杂的AI模型,实现高级功能。
    • 缺点:

      • 同步精度降低: 数据与画面的同步会引入几十到上百毫秒的"分析延迟"。
      • 部署成本: 需要额外部署和维护边缘计算硬件。
      • 技术复杂: 码流的实时解析和重构对技术要求较高。
4.3.1.3 云端注入 (在媒体服务器)

感觉不太建议了,不如直接websocket。

  • 注入模块: 部署在云端的媒体服务器(SFU/MCU,如Mediasoup, LiveKit, SRS)或音视频PaaS平台(声网、火山引擎等)。

  • 注入时机/方式:

    • 物联网设备将视频流推送到云端媒体服务器。
    • 你的业务后台、Web应用或其他云服务,通过调用媒体服务器提供的API(比如Agora SDK中的方法),请求向指定的视频流中注入数据。
    • 媒体服务器负责完成底层的SEI NALU生成和注入工作。
  • 注入的数据类型:

    • 与上层业务逻辑、用户交互或第三方服务相关的数据。

    • 示例:

      • 远程监控: 管理员在Web界面上观看实时视频,点击"标记异常"按钮。业务后台立即调用API,将"事件ID: xxx, 操作人: admin"作为SEI写入正在录制的视频流中,便于事后检索。
      • 智慧农业: 云端后台从天气API获取到最新的温度和湿度,将这些信息作为SEI注入到大棚的实时监控画面中,让所有观看者都能看到。
      • 云端录制: 在视频录制归档时,将数据库中与该视频相关的元数据(如设备ID、存储路径等)作为SEI写入,实现媒体文件和业务信息的自包含。
  • 场景与权衡:

    • 优点:

      • 开发最简单: 对于Web开发者最友好,只需调用高层API,无需关心底层码流操作。
      • 极高灵活性: 注入逻辑可以随时修改和部署,与业务系统紧密集成。
    • 缺点:

      • 同步精度最低: 数据与画面的同步延迟最大,包含了"设备->云端"的整段网络延迟。它同步的是"云端收到画面"的时刻,而非"设备捕捉画面"的时刻。

4.3.2 互联网场景

当随帧数据是来自平台的、需要广播给所有人的、或需要统一管理的权威信息时,服务端注入是更简单、更可靠的选择。

  • 注入主体 : 业务后台应用,通过指令控制云端的媒体服务器 (SFU/MCU)

  • 实现方式:

    • 这是最常见的工程实践。客户端(Web/App)通过常规信令通道(如WebSocket或HTTP API)将事件发送给业务后台。
    • 业务后台根据业务逻辑,调用媒体服务器(SRS或声网、火山引擎等PaaS平台)提供的高层API
    • 媒体服务器接收到API指令后,负责在转发视频流的过程中,找到合适的时机(如关键帧之前),将数据封装成SEI并注入到码流中。开发者完全无需关心底层的码流操作。
  • 注入的数据类型: 由平台集中控制、需要权威发布的同步信息。

    • 直播答题 : 服务器将题目和选项作为SEI注入到主播的视频流中,确保所有观众看到的题目与主播画面同步。
    • 电商直播: 服务器在特定时间点,注入**"优惠券"或"商品链接"**的弹出指令。
    • 视频会议 : 服务器根据发言策略,注入当前主讲人(Active Speaker)的用户ID ,或会议布局切换的指令。
    • 动态水印: 为防止盗录,服务器将观看者的用户ID作为动态、隐蔽的水印信息,注入到他所接收的视频流中。
  • 核心优势:

    • 实现简单可靠: 开发者只需调用简单的API,媒体服务器保证了注入的专业性和稳定性。
    • 中心化控制与安全: 平台是唯一的信息源,可以对数据进行校验、管理和控制,杜绝了客户端的恶意行为。
    • 全平台兼容: 注入逻辑在服务端,与客户端是iOS、Android还是Web无关,只需保证各端能解析即可。
  • 缺点:

    • 同步延迟更高: 整个流程包含一次"客户端 -> 服务端 -> 媒体服务器"的信令往返,延迟通常在百毫秒级别,不适合需要即时反馈的用户操作。

4.4 在H264 | H265 中如何找到并解析出SEI

4.4.1 SEI解析原理

借花献佛,参考以下文章,写的很详细

SEI补充增强信息(全网最全SEI指南) - 实时互动网

4.4.2 SEI解析实战

4.4.2.1 使用商业 RTC 厂商

许多项目为了加速开发、规避实时流媒体传输的复杂性,会选择使用商业化的实时通信(RTC)SDK。这些 SDK 通常将 SEI 的传输封装成简单的 API 调用。其实如果你使用了商业 RTC SDK的话,直接订阅他们暴露出来的事件就可以

  • 声网Agora

Interface ILocalVideoTrack | 文档中心 | 声网

  • 火山引擎

SEI 消息监听--视频直播-火山引擎

SDK(如声网 Agora、即构 Zego、火山引擎 RTC 等)提供的 sendStreamSyncInfosendSEIMsgsendStreamMessage 等函数,其底层实现通常就是利用 SEI 机制 。这种抽象让开发者无需直接操作 NAL 单元,极大地简化了开发。但便利性的代价是,开发者必须遵守 SDK 设定的各种限制。

4.4.2.2 使用原生WebRTC,如SRS

以JavaScript代码解析基于WebRTC的H264码流为例

js 复制代码
/**
 * 从H.264 NALU载荷中移除防止竞争码 (0x03)。
 * @param {Uint8Array} rawNaluPayload - 原始的NALU载荷数据 (不包含NALU头字节)。
 * @returns {Uint8Array} - 返回一个清除了所有 0x03 防止竞争码的新 Uint8Array。
 */
function removeEmulationPreventionBytes(rawNaluPayload) {
    const payloadSize = rawNaluPayload.length;
    // 创建一个足够大的临时缓冲区,最终会裁剪
    const cleanPayload = new Uint8Array(payloadSize);
    let cleanPayloadIndex = 0;

    for (let i = 0; i < payloadSize; ) {
        // 检查是否存在 00 00 03 序列
        if (i + 2 < payloadSize && rawNaluPayload[i] === 0x00 && rawNaluPayload[i + 1] === 0x00 && rawNaluPayload[i + 2] === 0x03) {
            // 复制前两个字节 0x00, 0x00
            cleanPayload[cleanPayloadIndex++] = rawNaluPayload[i];
            cleanPayload[cleanPayloadIndex++] = rawNaluPayload[i + 1];
            // 跳过 0x03 字节
            i += 3;
        } else {
            // 正常复制字节
            cleanPayload[cleanPayloadIndex++] = rawNaluPayload[i];
            i += 1;
        }
    }

    // 返回一个精确大小的、已清理的数组副本
    return cleanPayload.subarray(0, cleanPayloadIndex);
}

/**
 * 在Uint8Array中寻找NALU起始码。
 * @param {Uint8Array} data - 要搜索的二进制数据。
 * @param {number} [startIndex=0] - 开始搜索的索引。
 * @returns {{start: number, length: number} | null} - 返回起始码的位置和长度,如果未找到则返回null。
 */
function findNaluStart(data, startIndex = 0) {
    for (let i = startIndex; i < data.length - 3; i++) {
        if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1) { // 检查 '00 00 01'
            return { start: i, length: 3 };
        }
        if (i + 3 < data.length && data[i + 2] === 0 && data[i + 3] === 1) { // 检查 '00 00 00 01'
            return { start: i, length: 4 };
        }
    }
    return null;
}

/**
 * 处理单个NALU数据,检查其是否为SEI,并清理其载荷。
 * @param {Uint8Array} naluData - 单个NALU的二进制数据 (不包含起始码)。
 */
function processNaluForSei(naluData) {
    if (naluData.length < 1) {
        return;
    }
    const naluHeader = naluData[0];
    const naluType = naluHeader & 0x1F;

    if (naluType === 6) { // 是SEI NALU
        const rawSeiPayload = naluData.subarray(1);

        // !! 关键步骤:在解析前,移除防止竞争码 !!
        const cleanSeiPayload = removeEmulationPreventionBytes(rawSeiPayload);
        const hexPayload = Array.from(cleanSeiPayload).map(b => b.toString(16).padStart(2, '0')).join(' ');
    }
}

/**
 * 创建一个用于解析SEI的TransformStream处理器对象。
 * 该函数利用闭包来维护跨数据块的状态。
 * @returns {{transform: function(EncodedVideoChunk, TransformStreamDefaultController)}}
 */
function createSeiParsingTransform() {
    // 状态变量,通过闭包在多次transform调用之间保持。
    let remnantData = new Uint8Array(0);

    // 返回包含 transform 函数的对象
    return {
        transform(encodedFrame, controller) {
            // 1. 合并遗留数据和当前帧数据
            const frameData = new Uint8Array(encodedFrame.data);
            const fullData = new Uint8Array(remnantData.length + frameData.length);
            fullData.set(remnantData);
            fullData.set(frameData, remnantData.length);

            let searchIndex = 0;
            let lastNaluEnd = 0;

            // 2. 循环查找和处理NALU
            while (true) {
                const naluStartInfo = findNaluStart(fullData, searchIndex);
                
                if (naluStartInfo) {
                    const naluStart = naluStartInfo.start;
                    // 如果找到了一个起始码,它之前的数据就是上一个完整的NALU
                    if (lastNaluEnd > 0) {
                        const nalu = fullData.subarray(lastNaluEnd, naluStart);
                        processNaluForSei(nalu);
                    }
                    // 更新下一个NALU的起始位置和搜索索引
                    lastNaluEnd = naluStart + naluStartInfo.length;
                    searchIndex = lastNaluEnd;
                } else {
                    // 3. 没有更多起始码,将剩余数据存为遗留数据
                    remnantData = fullData.subarray(lastNaluEnd);
                    break;
                }
            }

            // 4. 将原始帧向下传递给解码器
            controller.enqueue(encodedFrame);
        }
    };
}

// 假设在 pc2.ontrack 回调函数内部
pc2.ontrack = (event) => {
    if (event.track.kind === 'video') {
        const receiver = event.receiver;
        
        // 1. 获取Insertable Streams的读写流
        const streams = receiver.createEncodedStreams();

        // 2. 使用工厂函数创建一个新的TransformStream
        //    每次创建都会生成一个拥有独立状态(remnantData)的新闭包
        const transformStream = new TransformStream(createSeiParsingTransform());

        // 3. 建立管道
        streams.readable
            .pipeThrough(transformStream)
            .pipeTo(streams.writable);
    }
    // ... 其他代码
};

我们用一个生活中常见的场景:瓶装水工厂,来解释上述代码处理过程

  • 水(数据) : 网络上源源不断传输过来的视频数据,就像是纯净水。
  • 编码后的视频帧 : 一瓶瓶封装好的、待检查的瓶装水。每瓶水就是一个 EncodedVideoChunk
  • WebRTC接收端 (RTCRtpReceiver) : 工厂里负责从水源接收瓶装水的传送带入口
  • 浏览器解码器: 工厂的装箱部门。它的任务是接收传送带上的瓶装水,检查无误后直接装箱(解码成图像)送去超市(显示在屏幕上)。

在没有Insertable Streams这个技术之前,这个工厂是全自动的、封闭的。你作为工厂的主管,只能看到传送带入口有水瓶进去,装箱部门有箱子出来。你完全无法接触到传送带上的任何一瓶水。

而你现在的任务(解析SEI),就好像是"想在每一瓶水上贴一个检验合格的标签,或者检查瓶盖上有没有隐藏的特殊标记(SEI)"。Insertable Streams(可插入流)允许你在传送带中间"切一刀",临时把水流引到你自己的一个工作台上,处理完再送回传送带。

核心代码分步解析

1.const receiver = event.receiver;

  • event : 此为 RTCTrackEvent 接口的一个实例。根据W3C WebRTC规范,当一个 RTCRtpReceiver 对象被关联到一个 RTCPeerConnection 时,此事件被触发。event 对象封装了新抵达的 MediaStreamTrack 及其对应的 RTCRtpReceiver
  • event.receiver : 此为 RTCRtpReceiver 接口的一个实例。其核心职责是接收、解包(RTP/RTCP)、并最终将媒体数据传递给解码器。它与一个特定的媒体轨道(MediaStreamTrack)和SSRC(Synchronization Source)绑定。
  • 目的: 此行代码旨在获取与新接收的远程媒体轨道相关联的 RTCRtpReceiver 对象的引用,该对象是后续所有操作的起点。
  • 在我们的工厂比喻中,receiver 就是那条特定的、负责接收视频瓶装水的传送带入口。它不停地从网络上接收编码好的视频帧(瓶装水)。

2. const streams = receiver.createEncodedStreams();

  • createEncodedStreams() : 这是 RTCRtpReceiver 上的一个方法,由 WebRTC-Insertable-Streams规范定义。调用此方法会中断默认的、浏览器内部的媒体处理路径。

  • 功能: 它将编码后的媒体帧流从内部解码器管道中**分流(divert)**出来,使其可被JavaScript访问。

  • 返回值 (streams) : 该方法返回一个包含两个流的对象:

  • streams.readable: 一个 ReadableStream 的实例。此流的控制器(Controller)会将从网络传输层接收到的、解码前的媒体帧作为数据块(chunk)进行排队。这些数据块是 EncodedVideoChunkEncodedAudioChunk 的实例(由 WebCodecs API 定义)。

  • 目的: 暴露编码媒体帧的处理管道,提供一个作为数据源的 ReadableStream 和一个作为解码器输入宿(sink)的 WritableStream

  • 在我们的工厂比喻中, createEncodedStreams() 是最神奇、最核心的一步。你走到了receiver(传送带入口)旁边,执行了 createEncodedStreams() 这个操作。 这就相当于你对工厂管理员说:"请把这条传送带在这里切开,并把两个断开头交给我!"streams 包含什么?** 这个函数会返回一个对象,里面有两个非常重要的东西:

    1. streams.readable: 一个可读流 。这是传送带被切开后的前半段的末端。所有从网络接收到的、未经处理的原始视频帧(瓶装水),现在都会从这个末端"流"出来。
    2. streams.writable: 一个可写流 。这是传送带被切开后的后半段的开端。它通往最终的解码器(装箱部门)。你需要把处理完的视频帧(贴好标签的瓶装水)"写"回到这个开端,才能让它们继续前进被解码。

3. const transformStream = new TransformStream(createSeiParsingTransform());

  • TransformStream: 这是由 Streams API 规范定义的标准接口。它是一个数据转换原语,内部将一个 WritableStream(其输入端)与一个 ReadableStream(其输出端)配对,形成一个可编程的中间处理节点。

  • 构造函数参数: TransformStream 的构造函数接受一个"转换器"(transformer)对象作为参数,该对象可以定义 starttransformflush 三个方法。

  • createSeiParsingTransform() : 这是一个工厂函数,其设计目的是返回一个符合"转换器"规范的对象。该对象的核心是 transform(chunk, controller) 方法,它封装了具体的SEI解析业务逻辑。通过闭包机制,此函数可以为每个TransformStream实例创建并维护独立的状态(如用于处理跨帧数据的 remnantData)。

  • 目的: 实例化一个自定义的流转换器。该 transformStream 将作为管道中的一个处理阶段,对流经它的每一个 EncodedVideoChunk 执行预定义的SEI解析算法。

  • 在我们工厂模式的比喻中:你可以把TransformStream 想象成一个"多功能处理工作台"。它天生就有一个入口和一个出口,你只需要告诉它在工作台内部需要做什么处理即可。createSeiParsingTransform() 是我们自己写的"工作指南" 。这个函数创建并返回了一套操作指令,其中最重要的指令就是 transform 函数(SEI解析逻辑)。这个transform函数定义了"如何处理流经工作台的每一件物品"(每一个视频帧/每一瓶水)。这行代码的意思就是,我们新建了一个"处理工作台" (TransformStream),并把我们写好的"SEI解析工作指南" (createSeiParsingTransform()) 交给了它。现在,这个transformStream就是一个定制好的、专门用于解析SEI的自动化处理站。

4. streams.readable.pipeThrough(transformStream).pipeTo(streams.writable);

这是一个声明式的管道构建语句,由两个链式调用组成,用于连接前面准备好的所有组件。

  • .pipeThrough(transformStream) :

    • 定义: ReadableStream.prototype.pipeThrough() 是一个高阶管道方法。它将一个 ReadableStream(源)通过一个 TransformStream(转换器)进行传输。
    • 机制: 在内部,此方法调用 sourceReadable.pipeTo(transformStream.writable),并返回 transformStream.readable。它有效地将源流连接到转换流的写端,同时将转换流的读端作为新的源流返回,这是一个用于简化管道链式调用的语法糖。
    • 效果: 将从receiver流出的 EncodedVideoChunk 定向到我们自定义的 transformStream 中进行处理。
  • .pipeTo(streams.writable):

    • 定义: ReadableStream.prototype.pipeTo() 是将 ReadableStream 连接到 WritableStream 的基础方法。
    • 机制: 此方法会持续从源 ReadableStream 读取数据块,并将其写入目标 WritableStream,直至源流关闭或任一端发生错误。它自动处理背压(backpressure) ,确保读取速度与写入速度相匹配,防止内存溢出。
    • 效果: 将经过 transformStream 处理后(可能被修改或仅被检查过)的 EncodedVideoChunk,从转换流的出口定向到浏览器解码器的入口(streams.writable)。

在我们工厂模式的比喻中,这行代码是把所有东西连接起来的"管道工"操作。它看起来复杂,但其实是两个连续的动作。

  • 第一部分: streams.readable.pipeThrough(transformStream)

    • pipeThrough 的字面意思是"通过管道穿过"。
    • 这个操作把 streams.readable(传送带的前半段末端)连接到了我们 transformStream(SEI处理工作台)的入口
    • 现在,所有的原始瓶装水都会自动地、一个接一个地流进我们的处理工作台。
    • 这个操作执行完后,会返回一个新的可读流,也就是我们处理工作台的出口
  • 第二部分: .pipeTo(streams.writable)

    • pipeTo 的字面意思是"用管道连接到"。
    • 这个操作紧接着上一步,把我们处理工作台的出口 连接到了 streams.writable(传送带的后半段开端)。
    • 现在,所有经过我们处理、检验、贴好标签的瓶装水,都会自动地、一个接一个地流回到主传送带上,继续前往装箱部门(解码器)。

从体系结构上看,这段代码实现了一个在WebRTC媒体接收端的编码域"中间人(man-in-the-middle)"拦截模型。

其数据流路径如下:

scss 复制代码
[RTP/RTCP Transport]
         |
         v
[RTCRtpReceiver] --createEncodedStreams()--> [Internal Split]
         |                                         ^
         | (streams.readable)                      | (streams.writable)
         v                                         |
[TransformStream: SEI Parser] --------------pipeTo()
         |
         | (Internal transform() logic)
         |
         |--pipeThrough()
         |
         v
[Internal Decoder]
         |
         v
[Rendered Video]

5. 对比分析:SEI 与 WebSocket

  • 带内(In-Band)SEI:元数据被复用到与音视频相同的比特流中。它在物理上是媒体容器或码流的一部分,与音视频数据共享同一个传输通道 。
  • 带外(Out-of-Band)WebSocket:HTTP/WebSocket/DataChannel属于"带外(Out-of-Band)数据",其传输路径、网络拥塞和调度策略与音视频流完全分离,因此"分道扬镳,各自为战",无法保证同步。

5.1 两者的区别

评估标准 带内 (SEI) 带外 (WebSocket) 决策指南
同步精度 帧精度。与视频帧固有同步,无需额外处理。 基于时间戳。需要手动同步,易受时钟漂移和网络抖动影响。 SEI: 要求绝对、有保障的同步,如交互式触发器、精确数据叠加。
通信方向 单向 (编码器 → 解码器)。 双向 (全双工)。 WebSocket: 需要双向通信,如聊天、客户端向服务器回传指令。
载荷大小 。最适合 KB 级别或更小的数据。受 SDK 实际限制 (如 1-4 KB)。 。适合高吞吐量、大数据载荷。 WebSocket: 发送大量数据或文件。
消息频率 受限。和视频帧率相关,(如 30Hz)。 。可处理非常频繁的消息传递。 WebSocket: 每秒发送大量消息。
系统复杂性 包含在媒体管道内。无需额外服务器或连接管理。 需要独立的服务器基础设施。增加了连接管理、状态维护和重连逻辑。 SEI: 希望最小化外部依赖和基础设施,简化系统架构。
内置重连 不适用。作为视频流的一部分,其可靠性由媒体传输协议保障。 。必须由应用程序客户端手动实现。 (对比项): 如果仅需简单的服务器到客户端单向推送且需要自动重连,SSE (Server-Sent Events) 是一个可行的替代方案 。

5.2 选型决策建议

在设计涉及实时视频和关联数据的系统时,应根据具体需求权衡不同技术的优劣。

  • 优先选择 SEI 的场景 :当应用的核心需求是小数据量、时间关键型 的元数据,且同步精度是首要考量时,SEI 是最佳选择。典型场景包括:交互式事件的精确触发、传感器快照数据的同步叠加、机器人遥测信息的帧同步显示等。
  • 优先选择 WebSocket 的场景 :当应用需要双向通信大数据量传输,或者对同步精度要求不高(基于时间戳的粗略同步即可满足)时,应选择 WebSocket。典型场景包括:实时聊天、客户端向服务器发送大量控制指令、文件传输等。
  • 考虑混合架构方案:在某些复杂场景下,混合使用两种技术可以取长补短。可以利用 SEI 发送一个轻量级的、时间精确的触发信号或数据 ID,客户端接收到该 SEI 信号后,再通过 WebSocket 或 REST API 使用该 ID 去请求获取更庞大的数据载荷。这种方法既保证了触发的实时性和同步性,又利用了带外通道处理大数据的能力。
  • 强制建立数据契约:无论选择何种方案,对于自定义数据,都必须在前后端(或推流端与播放端)之间建立一份明确、版本化的数据格式文档(Data Contract/Schema)。这应被视为项目开发的核心交付物之一,以确保系统的长期可维护性和互操作性。

6. 实践中的"坑"与最佳实践 (Challenges & Best Practices)

6.1 理解"解码同步" vs "渲染同步"

SEI真的可以完成帧级同步吗? SEI能保证数据与视频帧在码流层面是同步的,但这并不完全等同于用户在屏幕上看到的同步。

6.1.1 "坑"在哪里?

  • Jitter Buffer (抖动缓冲): 接收端为了应对网络抖动,会设置一个Jitter Buffer,它会缓存几帧视频,平滑网络延迟波动后再送去解码和渲染。这意味着,一个携带SEI的视频帧可能已经到达并被解码,但为了播放的平顺,它会在缓冲区里"等待"几十甚至上百毫秒后才被渲染到屏幕上。SEI事件(通常在解码后立即触发)与用户肉眼看到对应画面的时刻,存在一个微小且动态变化的延迟。对于直播答题这类场景,这个延迟通常可以接受。但对于音乐节奏游戏这类需要"帧"级渲染同步的应用,这个延迟是致命的。

以我们在Web端最常用的Insertable Streams方案为例:

我们的TransformStream拦截的是编码后的视频帧 (RTCEncodedVideoFrame)。这个动作发生在WebRTC流程中非常靠前的位置:RTP包被接收、解包、重排序并组装成一个完整的视频帧之后,但在送入解码器和Jitter Buffer之前。 所以,拿到并解析出SEI数据的那个时间点,几乎就是这个视频帧数据抵达客户端的时刻。我们把这个时刻称为 "解码同步点"。

现在,整个流程的关键就清晰了:

  • 解码同步 (Decode Sync): 在TransformStream中,开发者拿到了SEI数据,可以立即执行业务逻辑,这个时间点非常早,几乎没有延迟。

  • 渲染同步 (Render Sync): 这是界面用户感知层面的同步。携带该SEI的视频帧,在经过解码之后,并不会立即显示,而是要进入前面提到的Jitter Buffer这个"蓄水池"里排队等待。根据当前的网络状况,它可能会等待几十到几百毫秒后,才被取出并渲染到屏幕上,让用户肉眼看到。这个时刻才是 "渲染同步点"。

6.1.2 最佳实践:

  • 明确需求: 首先明确你的业务对同步精度的真实要求。是"看起来同步"(可接受百毫秒级误差),还是"渲染时同步"(误差需在16ms以内)。

  • 接受现实: 对于绝大多数WebRTC应用,接受"解码同步"的精度,并围绕它进行设计。

  • 人为控制web的渲染: 对于极端场景,需要引入基于NTP的统一时间戳,在SEI中携带"目标渲染时间戳",在渲染侧进行更精细的校准。但这极为复杂,通常不推荐。

6.2 Payload大小要控制!

SEI被设计为"补充增强信息",而非大容量数据传输管道。始终要保持其轻量,这是使用SEI的第一准则。

6.2.1 "坑"在哪里?

  • 码率膨胀: SEI的Payload会直接增加视频的整体码率。一个持续注入的、较大的SEI(例如每帧都带1KB的JSON字符串)会显著增加带宽消耗,尤其是在大规模并发场景下,成本会急剧上升。

  • 触发拥塞控制: 突然注入一个大的SEI,可能导致瞬时码率超过网络预测带宽,从而触发WebRTC的拥塞控制算法,导致视频分辨率、帧率的急剧下降,出现画面卡顿或模糊。

  • 处理开销: 在接收端,尤其是在JavaScript中解析每一个SEI,体积越大,CPU消耗也越高,可能影响低端设备的性能。

6.2.2 最佳实践:

  • 大小法则: 将SEI的Payload大小控制在几十到几百字节。原则上,单条SEI信息不应超过1KB。如果你的业务数据持续超过1KB,SEI可能不是最合适的工具。

  • 高效编码: 不要使用明文JSON。优先选择Protobuf、MessagePack或自定义的二进制格式,它们能将数据体积压缩数倍甚至数十倍。

  • 差量更新: 对于连续变化的数据(如白板笔迹),只发送增量信息(新的坐标点),而不是发送每一帧的全量状态。

  • 设计精简: 在设计数据结构时,多用整数枚举(Enum)代替字符串,使用布尔值(Boolean)代替0/1,精简每一个字节。

6.3 兼容性问题

SEI的理论标准和各家厂商的工程实现之间,存在着一道道看不见的墙。

6.3.1 "坑"在哪里?

  • 媒体服务器 (SFU) : 这是最常见也最隐蔽的坑。很多SFU为了"净化"码流以保证最大的兼容性,可能会默认丢弃它不认识的NALU单元,其中就包括我们的自定义SEI。你的SEI在发送端注入成功,却在服务器中转后就神秘消失了。

  • 编解码器差异: 虽然SEI是H.264/H.265标准的一部分,但不同编码器(如开源的x264, OpenH264,或硬件编码器)对SEI的支持和处理方式可能存在细微差别。AV1则使用完全不同的OBU机制来承载元数据。

  • 终端SDK的限制: 不同的平台(Web/iOS/Android)、不同的厂商SDK,对SEI的解析能力、支持的频率和大小都可能有自己的"潜规则"。例如,Web端的Insertable Streams就是一个仅在较新浏览器中才支持的强大功能。

6.3.2 最佳实践:

  • 验证SFU: 在技术选型阶段,务必在厂商文档中寻找"SEI passthrough"、"custom NALU support"等关键词,确认你的媒体服务器明确支持SEI透传。

  • 端到端验证测试: 在项目初期,搭建一个最小化的端到端链路(推流端 -> 媒体服务器 -> 拉流端),注入一个包含特定魔数(Magic Number)的SEI,验证所有目标终端都能准确接收并解析。这个测试比读任何文档都可靠。

6.4 SEI丢失与容错策略

既然SEI通常承载于UDP之上,就必须正视"丢失"的现实,并为之设计容错。

6.4.1 "坑"在哪里?

一个关键的SEI信令(如"开始答题")恰好位于一个丢失的IP包中,导致部分用户无法参与互动。

6.4.2 最佳实践:

  • 关键信息重复发送: 对于重要的SEI信令,不要只在一个视频帧中发送。可以在发送端连续N帧(或持续200ms)注入相同内容的SEI,大大增加接收端至少收到一次的概率。一些厂商SDK(如火山引擎)的API甚至直接提供了repeatCount这样的参数。

  • 绑定关键帧 (Keyframe): 确保全量状态或关键信令总是伴随视频的关键帧(IDR帧)发送。关键帧是解码的基石,媒体服务器在丢包策略上也会优先保护它们。

  • 设计带序列号的幂等操作: 在SEI的Payload中加入序列号(seq)或时间戳。接收端可以据此识别重复或过时的消息,保证多次收到同一指令也只执行一次(幂等性)。

6.5 性能开销:SEI的性能消耗

在客户端,特别是Web端,实时解析SEI是一项有成本的操作。

6.5.1 "坑"在哪里?

在低端手机或繁忙的Web页面上,通过Insertable Streams对每秒30帧视频进行NALU分割和解析,会持续消耗CPU资源,可能导致UI卡顿、动画掉帧,或设备发热和耗电加剧。

6.5.2 最佳实践:

  • 按需解析: 不要让SEI解析逻辑一直"空转"。仅在需要显示相关信息(如渲染画布打开时)才启动解析,在面板关闭时停止解析。

  • 优化解析代码: NALU解析代码不应该参杂其他业务,要确保它是纯粹高效的。避免在transform函数中进行复杂的DOM操作、内存分配等耗时任务,应尽快解析出数据,通过事件机制交给业务层去消费。

  • 性能监控: 在开发过程中,持续使用浏览器的Performance工具来分析SEI处理逻辑对主线程的影响,确保它不会成为性能瓶颈。

相关推荐
前端大卫17 分钟前
Vue 和 React 受控组件的区别!
前端
Hy行者勇哥38 分钟前
前端代码结构详解
前端
练习时长一年1 小时前
Spring代理的特点
java·前端·spring
水星记_1 小时前
时间轴组件开发:实现灵活的时间范围选择
前端·vue
2501_930124702 小时前
Linux之Shell编程(三)流程控制
linux·前端·chrome
潘小安2 小时前
『译』React useEffect:早知道这些调试技巧就好了
前端·react.js·面试
@大迁世界2 小时前
告别 React 中丑陋的导入路径,借助 Vite 的魔法
前端·javascript·react.js·前端框架·ecmascript
EndingCoder3 小时前
Electron Fiddle:快速实验与原型开发
前端·javascript·electron·前端框架
EndingCoder3 小时前
Electron 进程模型:主进程与渲染进程详解
前端·javascript·electron·前端框架
Nicholas683 小时前
flutter滚动视图之ScrollNotificationObserve源码解析(十)
前端