流媒体服务-传输延时(SEI插帧)

什么是延时

很多小伙伴认为,当推流端和拉流端显示的时间不一致,即为延时。

其实这种看法是比较片面的,不同的播放器,对同一路流进行测试,可能会得到不同的结果。

一般来说,延时为以下几个部分的累加组成

  • 采集延时

在采集摄像头或显卡画面时,由于fps的限制和cpu性能、内存拷贝速度等客观限制,采集画面成YUV/RGB等数据时会有一定的延时,一般延时为毫秒级别。由于一般编码器对输入数据格式存在限制,譬如要求统一输入YUV420P,这样在做RGB->YUV420P转换时,也会有转换计算延时(这个可以通过libyuv库来降低)。总而言之,采集延时大概为毫秒级别,如果fps为30,那么一般采集延时会有30毫秒以上的延时,在内存拷贝和颜色转换时,又可能增加若干毫秒的延时。

  • 编码延时

在把原始画面输入到编码器时,并不会立即输出编码后的数据,特别是在开启B帧时,由于需要参考后面的P帧,那么延时会更大,所以延时敏感的情况下一般不开启B帧,这种情况下编码延时应该是毫秒级别,不是很大。

  • 网络上行传输延时

编码后的数据,要经过一定的协议打包才能写入socket,然后传输给推流服务器或拉流代理服务器,协议打包会有一定的内存拷贝和计算量,那么会增加延时,不过这个延时很小,基本忽略不计。数据在上传到服务器时,这个延时可大可小,取决于网络质量。

  • 服务器转协议延时

服务器在收到数据后,要读socket缓存、协议解析、解复用、重新打包等操作,不过总体而言,这个延时比较小,基本没什么影响。有时,服务器为了提高性能,会采取合并写的机制,也就是收到一定量的数据后才会一并转发,这个延时一般为几百毫秒。不过一般服务器会默认不会打开此机制

  • 网络下行延时

流媒体在把视频数据转发给播放器时,会存在网络发送,这个延时大小取决于网络质量。

  • 播放器延时

播放器延时主要有网络接收延时、协议解析解复用延时、解码延时、缓存延时、渲染延时组成,这些延时中缓存延时最大,因为一般的播放器为了保证在网络抖动情况下视频播放的流畅性,会以增加延时为代价,增加播放缓存,这样在网络变差时,不至于播放缓冲卡顿。而且为了音视频同步,也必须确保一定的缓存量。这种延时一般都是秒级别,一般5秒左右。

  • 播放器GOP缓存延时

流媒体服务器为了能让播放器立即出画面,往往会缓存最近的一个I帧,这个I帧往后的所有音视频数据被称作为GOP缓存。如果不缓存GOP,那么播放器要等下一个I帧才能解码成功或不花屏,显然为了提高播放体验,这个GOP缓存是不能去掉的。而一般GOP短则1~3秒,长则10几秒,这个跟采集端编码器设置有关,服务器改变不了。但是由于一般的播放器收到缓存后,并不会丢弃过多的画面来确保低延时。况且播放器还希望有一定的缓存来确保播放的流畅性,所以这个GOP缓存将会增大播放器的延时。

  • 综合延时

以上所有的延时累加,就是你观看到的直观延时。通常大部分延时可能是由播放器造成,如果对播放器缓冲区感兴趣的同学可以参考这篇文章:https://zhuanlan.zhihu.com/p/51582357

如何计算延时

本文所讨论的延时为网络传输延时,也就是经过采集编码后的数据,经由推流端通过网络发送到到流媒体服务器流媒体服务器将数据通过网络推送到到拉流端的延时。

本文推荐使用在码流中混入SEI帧的方式来计算传输延时,具体步骤如下

  • 推流端在I帧之前插入SEI帧,内容为推流端时间戳
  • 拉流端在接收到SEI帧之后,解码出推流端时间戳,与拉流端时间戳对比,计算出延时

在这里插入图片描述

SEI 帧

先复习一下H264码流结构

  • H.264 原始码流组成结构

H.264 原始码流(裸流)是由一个接一个 NALU 组成。它的功能分为两层,VCL(视频编码层)和 NAL(网络提取层)。

为了方便从字节流中提取出 NALU,协议规定,在每个 NALU 的前面加上起始码(StartCode): 0x000001 或 0x00000001。

  • NALU 组成结构

NALU(NAL Unit)= 一组对应于视频编码的 NALU 头部信息(NAL header)+ 一个 RBSP(Raw Byte Sequence Payload,原始字节序列负荷)

NAL Unit Type 常⻅类型如下:

NAL Unit TypeNAL Unit Content
1非 IDR 图像,且不采用数据划分的片段。
5IDR 图像。
6补充增强信息(SEI)。
7序列参数集(SPS)。
8图像参数集(PPS)。
11流结束符。

那么NAL Unit Type6时,即为SEI帧。

SEI payload type 计算方式

当开始解析类型为 SEI 的 NAL 时,在 RBSP 中持续读取 8 bit,直到非 0xff 为止,然后把读取的数值累加,累加值即为 SEI payload type。

SEI RBSP 结构图如下:

SEI payload size 计算方式

读取 SEI payload size 的逻辑与 SEI payload type 类似,即读取到非 0xff 为止,这样可以支持任意⻓度的 SEI payload 添加。假设 SEI payload type 后面的字符序列是 FF FF AA BB …,则 FF FF AA 将会解析成 SEI payload size,为 255 + 255 + 170 = 680。

实例代码

// @brief: 将时间戳写入sei frame,将sei frame写入文件
#include <iostream>
#include <vector>
#include <cassert>
#include <fstream>
#include <string>
#include <chrono>

std::vector<uint8_t> MakeSei(const std::string& data)
{
    // 使用1个字节存储payload
    assert(data.size() + 16 < 255);

    std::vector<uint8_t> seiFrame;
    std::vector<uint8_t> uuid(16, 0x41);
    uint8_t              payloadSize = 16 + data.size();
    seiFrame.insert(seiFrame.end(), {0x00, 0x00, 0x00, 0x01}); // start code
    seiFrame.insert(seiFrame.end(), {0x06});                   // nalu type
    seiFrame.insert(seiFrame.end(), {0x05});                   // sei unregister data type
    seiFrame.push_back(payloadSize);                           // sei payload size
    seiFrame.insert(seiFrame.end(), uuid.begin(), uuid.end()); // uuid,这里可以替换成你自己的
    for (auto& ch : data)
        seiFrame.push_back(ch); // custom message
    seiFrame.push_back(0x80);   // rbsp trailing bits

    return seiFrame;
}

int main()
{
    std::ofstream file("sei.h264", std::ios::binary);
    std::string   data =
        "ts:" + std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count());
    auto sei = MakeSei(data);
    file.write(reinterpret_cast<char*>(sei.data()), sei.size());
    file.close();
    return 0;
}

这里在SEI中写入的数据格式为ts:{timestamp},你也可以定义为你希望的数据格式,如json,注意不要超过255 - 16个字节。

生成的帧信息如下:

推流端和拉流端如何进行时钟对齐

在拉流端拿到SEI frame之后,解码出推流端时间戳,计算delay = {拉流端时间戳} - {推流端时间戳}

这里存在一个问题是,拉流端系统时钟可能与推流端系统时钟不一致(如人为调整过系统时间),导致延时计算不准确,甚至是拉流端时间戳早于推流端时间戳。那么这时候就需要将推流端和拉流端的时间戳进行对齐

一般选择流媒体服务器时间戳进行对齐。

计算方法如下:

在这里插入图片描述

  • 参考文章

https://github.com/ZLMediaKit/ZLMediaKit/wiki/%E6%80%8E%E4%B9%88%E6%B5%8B%E8%AF%95ZLMediaKit%E7%9A%84%E5%BB%B6%E6%97%B6%EF%BC%9F

https://doc-zh.zego.im/faq/sei?product=ExpressAudio&platform=macos

GPAC是一个多媒体框架,它提供了一套用于处理多媒体数据的库函数。要往MP4文件中SEI,可以使用GPAC中的MP4Box工具或者使用GPAC库函数来实现。 以下是使用GPAC库函数往MP4文件中SEI的步骤: 1. 打开MP4文件 使用GPAC库函数中的MP4Read函数打开需要SEI的MP4文件,并创建一个MP4文件句柄。 ```C++ MP4FileHandle mp4 = MP4Read("test.mp4"); ``` 2. 获取视频轨道句柄 使用MP4GetTrackHandler函数获取视频轨道的句柄。 ```C++ MP4TrackId videoTrack = MP4FindTrackId(mp4, 0, MP4_VIDEO_TRACK_TYPE); MP4TrackHandle videoTrackHandle = MP4GetTrackHandler(mp4, videoTrack); ``` 3. 创建SEI数据 SEI是一种附加数据,用于传递一些额外的信息,如时间戳、场景描述等。需要根据SEI的格式,创建一个包含SEI数据的缓冲区。 ```C++ uint8_t seiData[] = {0x00, 0x00, 0x00, 0x01, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}; uint32_t seiDataSize = sizeof(seiData); ``` 4. SEI数据 使用MP4AddSEI函数将SEI数据入到视频轨道的每个中。 ```C++ MP4Duration videoDuration = MP4GetTrackDuration(mp4, videoTrack); MP4Duration videoTimeScale = MP4GetTrackTimeScale(mp4, videoTrack); MP4Timestamp videoTimestamp = 0; for (MP4Duration i = 0; i < videoDuration; i += videoTimeScale / 30) { MP4AddSEI(mp4, videoTrackHandle, seiData, seiDataSize, videoTimestamp); videoTimestamp += videoTimeScale / 30; } ``` 5. 保存MP4文件 使用MP4Write函数将修改后的MP4文件保存到磁盘上。 ```C++ MP4Write(mp4); ``` 6. 关闭MP4文件 使用MP4Close函数关闭MP4文件句柄。 ```C++ MP4Close(mp4); ``` 通过以上步骤,就可以往MP4文件中SEI数据。需要注意的是,SEI的格式需要符合MP4标准,否则可能会导致视频播放出现问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值