摘要:利用 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:

  1. 回放播放器播放至第 7s 时,有一条视频流进来,此时是从进度 0 的位置开始播放;

  2. 回放播放器播放至第 10s 时,该条视频流当前播放到了第 3s;

  3. 而在第 10s 的位置,此时帧节点中保存有 SEI 信息,记录的时间戳为 T2;

  4. 根据 T2 - T1 - 7s,得到该视频流的基准播放进度为 C;

  5. 如果 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即构科技​​​​​​​

Logo

致力于链接即构和开发者,提供实时互动和元宇宙领域的前沿洞察、技术分享和丰富的开发者活动,共建实时互动世界。

更多推荐