技术教程 —— 如何利用 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 ...
随机推荐
- xampp+vscode 安装PHP断点调试xdebug
官网下载地址:https://xdebug.org/download.php 这里需要特别注意,有TS(thread safe)和NTS 区别,我建议不要下载最新的,我一开始使用最新的发现插件没有匹配 ...
- Centos7 安装 rabbitmq-server-3.7.7 图文教程
下载 rabbitmq-server wget https://dl.bintray.com/rabbitmq/all/rabbitmq-server/3.7.7/rabbitmq-server-3. ...
- CGI,FastCGI和PHP-FPM之间的关系和区别
什么是CGI?早期的web server只可以处理简单的静态web文件,但是随着技术的发展出现动态语言如PHP,Python.PHP语言交给PHP解析器进行处理,但是处理之后如何和web server ...
- Django __init__ 方法用于初始化
使用面向对象的方法来创建一个栈板信息的模型,比如使用 Python 的类来表示栈板信息.以下是一个简单的示例: class Pallet: def __init__(self, number, nam ...
- Modbus转Profinet网关模块连PLC与流量计通讯案例
一.案例背景 在饮品加工厂中,会涉及到流量计的使用,然而达到对流量计的精准控制和数据采集需要用到PLC,由于PLC和流量计可能使用不同的通信协议(如Profinet和Modbus),造成两者不能自接进 ...
- 技术文档必备工具:注释目录树神器 Annotree,我的第一个正式开源项目
hi,大家好,我是爱听书的程序员阿超 非常开心能在这里介绍我的第一个正式开源项目 Annotree,项目具体情况如下,请继续阅读~ Annotree 注释树 一款生成带注释的目录树工具,大大方便技术文 ...
- 如何查询MySQL存储的树形结构,层次结构
表定义如下 如果我们需要在表中查询这个树状结构,通过SQL语句,有两种查询方法: 1.通过inner自连接查询,适用于简单的结构 SELECT * FROM course_category AS on ...
- wireshark抓包分析数据
wireshark抓包分析数据 https://www.cnblogs.com/moonbaby/p/10528401.html https://blog.csdn.net/wangyiyungw/a ...
- (二)MongoDB的在SpringBoot中的应用
我来填之前MongoDB的坑了,项目中又用到MongoDB的我又想起来了,我这拖延症也是没谁了. 1.在pom.xml中引入依赖 <dependency> <groupId>o ...
- 普通用户权限运行docker
docker安装后默认权限是管理员,在Ubuntu系统中需要使用sudo命令,但是很多时候docker的拉取操作都是写在脚步里面的,因此执行的时候十分的难搞,如果给脚本sudo权限后那么整个的环境路径 ...