技术教程 —— 如何利用 SEI 实现音画同步?
摘要:利用 SEI 解决数据流录制回放过程中的音画不同步问题。


文|即构 Web SDK 开发团队
今年 6 月, ZEGO 即构科技推出了行业内首套数据流录制 PaaS 方案,打破传统录制服务传统,实现 100% 录制还原效果(点击查看方案介绍文章)。
在实现数据流录制回放的过程中,我们需要将音视频画面和白板画面组合成一个回放画面,模拟成播放器进行同步播放。在此过程中,有时会因为网络抖动等原因,导致录制的音视频出现卡顿,如果不及时进行处理,将会出现回放进度和录制过程、音视频画面和其他画面等不同步的现象。
那么,面对这种情况,我们该如何处理?
本篇文章我们将从 SEI 的基础概念出发,结合数据流录制回放的需求和应用场景,带大家了解一下 ZEGO 即构科技 是如何利用 SEI 去解决音画不同步的问题,以及开发过程中可能踩到的坑。
一、什么是 SEI
1、SEI 简介
SEI,即补充增强信息(Supplemental Enhancement Information),属于码流范畴,它提供了向视频码流中加入额外信息的方法,是 H.264/H.265 这些视频压缩标准的特性之一。
在 H264/AVC 编码格式中 NAL uint 中的头部, 有 type 字段指明 NAL uint 的类型, 当 “type = 6” 时,该 NAL uint 携带的信息即为 补充增强信息(SEI)。
在视频内容的生成端传输过程中,都可以插入 SEI 信息。
2、SEI 基本特征
并非解码过程的必须选项。也就是说,SEI 对解码过程无直接影响。
可能对解码过程(容错、纠错)有帮助,可以根据 SEI 中插入的信息在解码过程中编写逻辑。
集成在视频码流中,从码流中去读取。
3、SEI 应用
利用 SEI 可以存储数据的特性,还可以实现如下功能:
传递编码器参数
传递视频版权信息
传递摄像头参数
传递内容生成过程中的剪辑事件
传递自定义消息
企业可以根据自身业务场景需求,利用 SEI 的特性去实现业务功能。
二、如何使用 SEI 实现业务逻辑
下面我们将以 web端 为切入点,带大家了解一下 SEI 的读取过程及其应用。
1、在视频码流中插入 SEI
在实现读取 SEI 之前,必须要在音视频码流中插入 SEI。大家可以了解一下SEI 的插入方式及规则,具体操作步骤可在网络进行搜索了解。
2、在 Web 平台进行读取
hjplayer.js 是一款音视频插件,它能够将 FLV 文件流和 HLS 的 TS 文件流经过解码和转码,转换为 Fragmented MP4,然后通过 Media Source Extensions API 将 mp4 片段填充到 HTML5,它提供了 SEI 信息的回调方法。
插件初始化:
const videoElement = document.getElementById('videoElement');
const player = new HJPlayer({
type: 'flv',
url: 'http://xxx.xxx.com/xxxx.flv',
});
player.on(HJPlayer.Events.GET_SEI_INFO, (e) => {
console.log(e); // SEI Message
});

该回调方法提供了读取到的 SEI 返回的信息,但该 SEI 信息并不是对应当前视频播放进度,而是当前视频缓存读取的进度。也就是说,当前回调返回的不是当前播放帧的 SEI,而是未来帧的SEI,此时我们就需要知道返回的这条 SEI 对应着哪一帧。
3、获取当前 SEI 返回的位置
要获取 SEI 返回位置,需要根据 hjplayer.js 的源码进行改造。
在改造之前我们需要了解 SEI 读取的原理:
首先 hjplayer.js 基于 flv.js 封装。其工作原理是:将 FLV 文件流转码复用成 ISO BMFF(MP4 碎片)片段,然后通过 Media Source Extensions,将 MP4 片段设置到原生的 HTML5 Video 标签中,进行播放;
然后,在 FLV 文件流转码复用的过程中,会对该 MP4 片段进行解析,通过解析 NALU 携带的信息,就可以拿到 SEI 信息。
因为是以片段为单位进行解析,所以我们无法准确知道每一条 SEI 的具体位置,但是可以知道含 SEI 片段的具体位置,算出该片段的具体位置,即可得到该 SEI 的大致的位置。
下面我们通过改造 hjplayer.js 的源码,获取该包含 SEI 片段的位置。话不多说,让我们一起看下改造后的源码:
// HJPlayer/src/Codecs/FLVCodec/Demuxer/FLVDemuxer.ts
_parseAVCVideoData(
arrayBuffer: ArrayBuffer,
dataOffset: number,
dataSize: number,
tagTimestamp: number,
tagPosition: number,
frameType: number,
cts: number
) {
const le = this._littleEndian;
const v = new DataView(arrayBuffer, dataOffset, dataSize);
const units: Array<NALUnit> = [];
let length = 0;
let offset = 0;
const lengthSize = this._naluLengthSize;
const dts = this._timestampBase + tagTimestamp;
let isKeyframe = frameType === 1; // from FLV Frame Type constants
while(offset < dataSize) {
if(offset + 4 >= dataSize) {
Log.warn(
this.Tag,
`Malformed Nalu near timestamp ${dts}, offset = ${offset}, dataSize = ${dataSize}`
);
break; // data not enough for next Nalu
}
// Nalu with length-header (AVC1)
let naluSize = v.getUint32(offset, !le); // Big-Endian read
if(lengthSize === 3) {
naluSize >>>= 8;
}
if(naluSize > dataSize - lengthSize) {
Log.warn(this.Tag, `Malformed Nalus near timestamp ${dts}, NaluSize > DataSize!`);
return;
}
const unitType = v.getUint8(offset + lengthSize) & 0x1f;
if(unitType === 5) {
// IDR
isKeyframe = true;
}
const data = new Uint8Array(arrayBuffer, dataOffset + offset, lengthSize + naluSize);
const unit: NALUnit = { type: unitType, data };
if(unit.type === 6) {
// 获取到SEI信息
try {
const unitArray: Uint8Array = data.subarray(lengthSize);
// 新增 tagPosition 回调参数,返回当前读取片段的位置
this.eventEmitter.emit(Events.GET_SEI_INFO, { sei: unitArray, tagPosition });
} catch (e) {
Log.log(this.Tag, 'parse sei info error!');
}
}
units.push(unit);
length += data.byteLength;
offset += lengthSize + naluSize;
}
if(units.length) {
const track = this._videoTrack;
const avcSample: AvcSampleData = {
units,
length,
isKeyframe,
dts,
cts,
pts: dts + cts
};
if(isKeyframe) {
avcSample.fileposition = tagPosition;
}
track.samples.push(avcSample);
track.length += length;
}
}

在上面的源码中,_parseAVCVideoData 方法中解析了 SEI 信息,tagPosition 参数是用于标识当前读取片段的位置,在触发 Events.GET_SEI_INFO 回调的位置,暴露该参数,用 tagPosition 除以视频资源的总字节长度 totalLength,得到读取位置的百分比,即可算出该 SEI 对应的大致位置。
如果想要知道更准确的 SEI 位置,可以每次读取更小的片段,从而使得计算更为准确,当然这也会增加一定的性能消耗。
4、利用 SEI 存储的时间戳校正视频进度
利用 SEI 可以存储数据的特性,在 SEI 内存储视频流播放位置的时间戳,根据这个数据作为一个播放时长基准。
思路如下:
步骤一:计算当前 SEI 记录的位置,比如是第 10s 返回的 SEI;
步骤二:根据计算出的 SEI 位置,找出当前 SEI 位置对应的帧节点,并将当前 SEI 记录的时间戳保存在帧节点数据中;
步骤三:根据时间戳和开始播放时间,计算出当前帧该视频的基准进度,如果视频进度和基准进度相差大于一定阈值则校正回基准进度。
下面我们以一个例子,理解上述思路:


上图是回放播放器某段区域的时间轴,假设回放播放器开始播放时的时间戳记录为 T1:
回放播放器播放至第 7s 时,有一条视频流进来,此时是从进度 0 的位置开始播放;
回放播放器播放至第 10s 时,该条视频流当前播放到了第 3s;
而在第 10s 的位置,此时帧节点中保存有 SEI 信息,记录的时间戳为 T2;
根据 T2 - T1 - 7s,得到该视频流的基准播放进度为 C;
如果 C 减去当前视频流进度 3s(即 c - 3s),大于 0.5s 的话则将当前的视频流进度调整为 C,确保当前视频流画面和其他非视频流画面同步展示。
以上就是利用 SEI 存储的时间戳,校正视频进度的过程,保证了回放的过程中的音画同步。
三、hjplayer.js 踩坑及填坑技巧
在使用 hjplayer.js 插件获取 SEI 的同时,我们还会用它来进行一些音视频的基本操作,例如播放、快进快退等。
在使用的过程中会出现以下常见的问题,下面将针对具体的情况进行讲解。
问题一:waiting 状态的处理
当用户将视频进度调整至未缓存区域之后,当前视频会出现 waiting 状态,导致视频显示 loading 并无法正常播放和跳转,这时就需要调用 player 实例的 unload、load 方法进行视频的重新加载。
示例代码如下:
const videoElement = document.getElementById('videoElement');
const player = new HJPlayer({
type: 'flv',
url: 'http://xxx.xxx.com/xxxx.flv',
}, {
...user config
}
);
player.attachMediaElement(videoElement);
player.load();
player.play();
// ...
videoElement.addEventListener('waiting', () => {
player.unload();
player.load();
});

问题二:跳转至未缓存区域的处理
当用户将视频进度调整至未缓存区域时,视频画面会出现一个 loading 图标,并会停止在当前进度,无法正常跳转和播放,视频处于 waiting 状态,如下图所示:


我们可以通过下面的操作来避免这个问题:
步骤一:设置 lazyLoad 属性
const videoElement = document.getElementById('videoElement');
const player = new HJPlayer({
type: 'flv',
url: 'http://xxx.xxx.com/xxxx.flv',
}, {
lazyload: false,
...user config
}
);

设置 lazyLoad 属性为 false,表明当视频缓存足够长时,不会断开 HTTP 链接。但如果加载的是比较长的视频时,缓存到一定进度还是会停止往后加载;
步骤二:监听缓存进度,并将其挂载在 player 实例上
videoElement.addEventListener('process', () => {
const len = video.buffered.length;
if (len) {
player.process = video.buffered.end(len - 1);
}
});

从缓存进度的监听回调中,记录当前视频的缓存进度。
步骤三:调整跳转进度方法
function seek(targetTime) {
if (player.task) return;
player.task = setInterval(() => {
const process = player.process;
if (targetTime > process) {
videoElement.currentTime = process - 2;
} else {
videoElement.currentTime = targetTime;
clearInterval(player.task);
player.task = null;
}
}, 100);
}

通过定时器,轮询当前缓存进度,如果当前的缓存进度小于目标进度,则将当前的播放进度调整至缓存进度差不多的位置,此时就能主动触发请求缓存资源,直至缓存到目标进度。
至此,跳转至未缓存区域问题已处理完毕。
四、总结
数据流录制是将教育企业的自研技术进行优化加码所形成的一套便捷高效、接入即用的标准化PaaS方案,打破传统录制服务,实现 100% 录制还原效果。
以上就是本篇文章关于补充增强信息(SEI)的解读及应用,即构科技利用 FLV 音视频携带的 SEI ,携带一些校验信息,校验音视频的基准播放时长,利用 SEI 实现多个回放画面的实时同步,最高程度的还原了直播现场,提升录制回看的质量。
更多关于数据流录制的详细信息,可查看即构科技官方文档,点击了解:Web 数据流录制示例源码下载 - 开发者中心 - ZEGO即构科技

技术教程 —— 如何利用 SEI 实现音画同步?的更多相关文章
- 在FPS游戏中,玩家对音画同步感知的量化与评估
前言 在游戏测试中,音画同步测试是个难点(所谓游戏音画同步:游戏中,音效与画面的同步程度),现在一般采用人工主观判断的方式测试,但这会带来2个问题: 无法准确量化,针对同一场景的多次测试结果可能会相反 ...
- ffmpeg protocol concat 进行ts流合并视频的时间戳计算及其音画同步方式一点浅析
ffmpeg protocol concat 进行ts流合并视频的时间戳计算及音画同步方式一点浅析 目录 ffmpeg protocol concat 进行ts流合并视频的时间戳计算及音画同步方式一点 ...
- QQ视频直播架构及原理 流畅与低延迟之间做平衡 音画如何做同步?
QQ视频直播架构及原理 - tianyu的专栏 - CSDN博客 https://blog.csdn.net/wishfly/article/details/53035342 作者:王宇(腾讯音视频高 ...
- 腾讯技术分享:微信小程序音视频与WebRTC互通的技术思路和实践
1.概述 本文来自腾讯视频云终端技术总监rexchang(常青)技术分享,内容分别介绍了微信小程序视音视频和WebRTC的技术特征.差异等,并针对两者的技术差异分享和总结了微信小程序视音视频和WebR ...
- 腾讯技术分享:微信小程序音视频技术背后的故事
1.引言 微信小程序自2017年1月9日正式对外公布以来,越来越受到关注和重视,小程序上的各种技术体验也越来越丰富.而音视频作为高速移动网络时代下增长最快的应用形式之一,在微信小程序中也当然不能错过. ...
- 吐血分享:QQ群霸屏技术教程之霸屏实施细则
小号,再不养,成本抗不住了;QQ群,再不玩,真的就玩不动啦. 霸屏系列,坚持下来差不多10来篇,最近更新的几篇,算是霸屏系列的更新版,毕竟相当的规则变动了. 经营自己,是一种前瞻能力,霸屏十篇,有多少 ...
- 深度技术Win7系统利用diskpart命令实现硬盘分区的技巧
转自:http://www.xitongcheng.com/jiaocheng/win7_article_2491.html 1. 深度技术Win7系统利用diskpart命令实现硬盘分区的技巧分享给 ...
- 好程序员技术教程分享JavaScript运动框架
好程序员技术教程分享JavaScript运动框架,有需要的朋友可以参考下. JavaScript的运动,即让某元素的某些属性由一个值变到另一个值的过程.如让div的width属性由200px变到400 ...
- WebGl 利用drawArrays、drawElements画三角形
效果: 代码: <!DOCTYPE html> <html lang="en"> <head> <meta charset="U ...
- 如何利用R包qqman画曼哈顿图?
如何利用R包qqman画曼哈顿图? 2017-07-10 lili 生信人 众多周知,R语言提供了各种各样的包,方便实现我们的目的,下面给大家介绍一个可以便捷的画曼哈顿图的包:qqman instal ...
随机推荐
- Nginx 可视化配置神器NginxConfig
Nginx 是前后端开发工程师必须掌握的神器.该神器有很多使用场景:比如反向代理.负载均衡.动静分离.跨域等等. 把 Nginx 下载下来打开 conf 文件夹的 nginx.conf 文件,Ngin ...
- 推荐王牌远程桌面软件Getscreen,所有的远程桌面软件中使用最简单的一个
今天要推荐的远程桌面软件就是这款叫Getscreen的,推荐理由挺简单: 简单易用:只需要两步就能轻松连上远程桌面 第一步:在需要被远程连接的机器上下载它的Agent程序并启动,点击Send获得一个链 ...
- 解决方案 | tk.entry数字验证(输入框如何保证只能输入数字)
from tkinter import * root = Tk() # 创建文本框 entry = Entry(root) entry.pack() # 设置文本框只能输入数字 entry.confi ...
- LeViT:Facebook提出推理优化的混合ViT主干网络 | ICCV 2021
论文提出了用于快速图像分类推理的混合神经网络LeVIT,在不同的硬件平台上进行不同的效率衡量标准的测试.总体而言,LeViT在速度/准确性权衡方面明显优于现有的卷积神经网络和ViT,比如在80%的Im ...
- vscode配置项
因为vscode的默认配置,导致现在用的不是很舒服.总结了以下配置能让你的vscode用着更舒服. 1: 问题: 输入log按tab快速生成代码后,提示居然没了? 解决方案: "editor ...
- TokenObtainPairSerialize
TokenObtainPairSerializer是Django REST framework的SimpleJWT库提供的序列化器.它用于对用户凭据(如用户名和密码)进行序列化和验证,并在成功的身份验 ...
- Linux 提权-密码搜寻
本文通过 Google 翻译 Password Hunting – Linux Privilege Escalation 这篇文章所产生,本人仅是对机器翻译中部分表达别扭的字词进行了校正及个别注释补充 ...
- 华为matebook 14s笔记本,Chrome浏览器开启硬件加速,屏幕闪屏,黑框,页面屏幕卡死,解决办法
解决办法使用了 https://zhuanlan.zhihu.com/p/644296061 这个连接下的最后一个折中办法解决! 一.现象 Chrome开启"硬件加速模式"后,在观 ...
- 【MySQL】Windows-8.0.19 安装版 下载安装
下载地址 https://dev.mysql.com/downloads/windows/installer/8.0.html 跳过登陆 只选择基本服务 安装依赖环境,如果已存在,圆圈显示绿点,下一步 ...
- AI未来应用的新领域:具有领域知识的专属智能拼音输入法 —— 医生专属的智能输入法
本人上个月去辽宁中医看了些小毛病,在和医生交流的时候随便小聊一下,其中一个主要的话题就是"医生是否需要练习五笔".众所周知,医生的主要工作是看病,而需要使用输入法打字写病历只是看病 ...