技术教程 —— 如何利用 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 ...
随机推荐
- Unable to start web server; nested exception is org.springframework.context.ApplicationContextException
项目报错:Unable to start web server; nested exception is org.springframework.context.ApplicationContextE ...
- LabVIEW Actor Framwork (1)________ 边学边做server&client
初始需求: 现在要做一个类似聊天的demo,一个server端,若干个client端:首先是server启动,通过server可以打开若干个client端,然后每个client可以独立给server发 ...
- mysql order by 造成语句 执行计划中Using filesort,Using temporary相关语句的优化解决
mysql> explain select permission.* from t_rbac_permission permission inner JOIN t_rbac_acl ...
- django 中的collectstatic
django 中的collectstatic 在Django中,"collectstatic"是一个管理命令,用于收集和复制项目中的静态文件到一个指定的静态文件目录,以便于部署. ...
- MySQL常用语句(经常容易忘记)
MySQL常用语句 一.连接MySQL 格式: mysql -h <主机地址> -u<用户名> -p<用户密码> --port=<端口号> 1.例1:连 ...
- 【MySQL】LEFT JOIN 踩坑
一.问题发现: 主查询功能发现两条一样的记录,但是审批状态不一样,一个已通过,一个待审核 主表付款表: CREATE TABLE `pur_or_payment` ( `id` int(11) NOT ...
- 【CI/CD】Centos7 下载安装 Jenkins
一.Docker安装Jenkins 参考: https://www.bilibili.com/video/BV11B4y1W7eH?p=5 安装Jenkins最新稳定版镜像: [root@Centos ...
- 【Java】Input,Output,Stream I/O流 01 概述 & 4个基础流
Input,Output,Stream IO流 I/O Input/Output缩写.I/O技术用于处理设备之间的数据传输,读写文件,网络通讯 Java程序对于数据的操作以Stream流的形式进行 i ...
- 同策略强化学习算法可以使用经验缓存池(experience buffer)吗 ??? 设计一个基于缓存池的改进reinforce算法,给出初步的尝试 ---------- (reinforce + experience buffer)
本文使用代码地址: https://gitee.com/devilmaycry812839668/reinforce_with_-experience-buffer ================= ...
- configure: error: Can't find GL/gl.h. Look for Mesa devel packages for your distro.
1. 安装文件查询工具 sudo apt install plocate 2. 查询头文件地址,shell命令: locate GL/gl.h 3. 为编译时指定其他的头文件查询地址: export ...