• HLS + ffmpeg 实现动态码流视频服务


    一、简介

    如下图,包含三部分,右边一列为边缘节点;中间一列代表数据中心;左边一列是项目为客户提供的一系列web管理工具:

    具体来说在我们项目中有一堆边缘节点,每个节点上部署一台强大的GPU服务器及N个网络摄像头,服务器持续记录摄像头的高清码流,同时跑模型持续分析视频内容;边缘服务器与数据中心之间有一条网络链路,但带宽非常小,通常只作数据、控制命令下发用;数据中心部署了一系列web服务,为不同用户提供审阅系统运行情况及发布操作边缘节点命令的端口。

    那么问题来了,终端用户通常并不关心摄像头录制到的视频,但偶尔需要抽检部分视频文件确定系统正在正常运行,出问题的时候算法团队需要导出原始高清视频作进一步分析,怎么破?提炼一下关键条件:

    1. 数量众多的边缘节点,每天生成海量视频

    2. 边缘节点到数据中心带宽有限

    3. 需要提供不同清晰度的视频满足不同场景需要

    最直观的方案是,将节点上的视频不断推送到数据中心,客户端直接访问存储在数据中心的视频数据,但这明显不符合场景要求,因为边缘节点到数据中心的带宽非常小,没办法支持高清视频文件的持续传输,而且抽检频率很低,全传回来了大多数也是用不上的。

    第二种方案可以选择按需调度,即由客户明确发出抽调命令,指定时间范围、边缘节点列表、摄像头列表、清晰度,数据中心按需同步。这种方案有两个问题,一是延迟大,命令从客户端发出后,得等数据中心到边缘节点捞完数据,才能开始推送视频,开始响应;二是需要实现一套调度系统,实现一堆提高可用性的逻辑,比如监控带宽防止打满、实现断点续输、异步任务生命周期管理等。

    第三种,也就是本文阐述的技术方案:使用 ffmpeg 动态调整视频码率、分辨率;使用 HLS 分段传输视频内容。

    如果读者想到更多可能性,还请联系作者,我们可以讨论讨论。

    二、核心技术

    2.1 ffmpeg 简介

    ffmpeg 是一个非常有名的高性能音视频处理工具,它可以轻松实现视频转码、分割、码率调整、分辨率调整、元数据解析、帧包解析等等,能满足大多数视频处理场景。网上已经有很多相关的讨论文章,本文就不赘述了。

    提示:

    贴几个有用的入门链接:

    1. ffmpeg 下载安装

    2. ffmpeg 镜像,同时支持CPU、GPU版本

    3. 阮一峰的 《ffmpeg 视频处理入门教程

    4. 使用GPU硬件加速ffmpeg视频转码

    2.2 HLS 协议简介

    HLS 全称 Http Live Stream,是苹果推出的基于http的流媒体传输协议,原理是将一个大的,完整的视频文件拆解成多个小文件,每次只播放其中的一个小文件。选用HLS主要有如下的考量:

    1. 协议底层使用http传输内容,这在大B环境下是个极大的利好,因为大多数企业内网防火墙有很严格的管控,唯独对80端口开放性较高

    2. 支持按需加载,也就是说播到那就加载那一块小文件,播多少下载多少,不用download整个视频

    3. 天然支持定点播放(seeking)

    当时在做技术选型时也考虑过DASH,虽然DASH更有协议上的先进性,但当下覆盖度低,兼容性没HLS好,贸然选用怕是得给后面埋坑啊。

    三、 代码实现

    核心代码其实特别简单,只需实现两个接口:

    1. 获取索引文件接口,原理上使用 ffprobe 分析视频 I 帧分布时间点,生成 m3u8 文件

    2. 获取分片视频,原理上使用ffmpeg 分割、转码,再通过http输出切割后的视频文件

    示例:

    1. import Router from 'koa-router';
    2. import { exec, spawn } from 'child_process';
    3. import config from 'config';
    4. import path from 'path';
    5. const route = new Router();
    6. const execPromise = (...arg) =>
    7.  new Promise((r, j) => {
    8.    exec(...arg, (err, stdout) => {
    9.      if (err) {
    10.        j(err);
    11.     } else {
    12.        r(stdout);
    13.     }
    14.   });
    15. });
    16. // 视频 m3u8 索引文件接口
    17. route.get('/videos/:videoFile', async (ctx) => {
    18.  const { videoFile } = ctx.params;
    19.  const videoFilePath = path.join(config.get('VIDEO_ROOT_DIR'), videoFile);
    20.  // 调用 ffprobe 分析视频 I 帧分布
    21.  const cmdReadKeyframe = `ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 ${videoFilePath}`;
    22.  const keyframes = (await execPromise(cmdReadKeyframe))
    23.   .split('\n')
    24.   .filter((d) => /^[\d\.]+$/.test(d))
    25.   .map((d) => +d);
    26.  // 调用 ffprobe 解析视频原信息
    27.  const cmdReadMeta = `ffprobe -v quiet -print_format json -show_format -show_streams ${videoFilePath}`;
    28.  const res = JSON.parse(await execPromise(cmdReadMeta));
    29.  const {
    30.    format: { duration }
    31. } = res;
    32.  // 根据关键帧的时间分布和视频时长,生成 m3u8 文件
    33.  const fragments = keyframes.map((k, i) => ({
    34.    duration: i === keyframes.length - 1 ? duration - k : keyframes[i + 1] - k,
    35.    start: k
    36. }));
    37.  ctx.body =
    38.   '#EXTM3U\n' +
    39.      '#EXT-X-PLAYLIST-TYPE:VOD\n' +
    40.      `#EXT-X-TARGETDURATION:${Math.max(...fragments.map((f) => f.duration))}\n` +
    41.   `${fragments
    42.       .map(
    43.         (f) =>
    44.            `#EXTINF:${f.duration},\n/hls_video/${videoFile}?start=${f.start}&duration=${f.duration}`
    45.       )
    46.       .join('\n')}\n` +
    47. '#EXT-X-ENDLIST';
    48.  ctx.type = 'application/x-mpegURL';
    49. });
    50. // 读取视频片段
    51. route.get('/hls_video/:videoFile', (ctx) => {
    52.  const {
    53.    params: { videoFile },
    54.    query: { start, duration }
    55. } = ctx;
    56.  const videoFilePath = path.join(config.get('VIDEO_ROOT_DIR'), videoFile);
    57.  // ffmpeg 截取视频片段
    58.  const cmd = `-ss ${start} -i ${videoFilePath} -t ${duration} -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset ${start} -`;
    59.  const { stdout } = spawn('ffmpeg', cmd.split(' '));
    60.  ctx.type = 'video/MP2T';
    61.  // 流式输出转码字节序
    62.  ctx.body = stdout;
    63. });
    64. export default route;

    下面讲解关键步骤。

    文末名片免费领取音视频开发学习资料,内容包括(C/C++,Linux 服务器开发,FFmpeg ,webRTC ,rtmp ,hls ,rtsp ,ffplay ,srs)以及音视频学习路线图等等。

    3.1 根据 I 帧分割视频

    HLS 的原理是将一个大的视频拆解为多个小视频按需传输,拆解策略的好坏直接影响视频还原度、转码效率、响应时间、缓存命中率等。可选的分割方案有:

    1. 根据视频duration,均匀切割,好处是简单直观,但要么转换速度很慢导致响应时间高,要么还原度低,切出来的视频可能存在好几秒偏差(参考 《FFmpeg 视频分割和合并》)

    2. 根据视频 I 帧所在时间点进行切割,好处是还原度高,不容易掉帧;缺点是需要先用 ffprobe 遍历视频中所有关键帧的位置,可能存在性能问题。

    提示:

    简单科普一下,经过压缩编码的视频主要包含了连续n个帧,其中有一种叫做 I 帧(Intra-coded picture),特点是压缩率低但是内容完整,不需要其他帧辅助推算;除了I帧外常见的还有 P‑帧 (Predicted picture) —— 向前搜索帧,B‑frame (Bidirectional predicted picture) 双向搜索帧,它们的压缩率高,但是需要依靠附近的I帧内容推算图像。

    通常 ffmpeg 截取视频时大致上不是按 -ss-t 指定的起始结束时间精确的裁剪,而是找到开始、结束时间点上最近的关键帧,截取两帧之间的数据,这会导致实际结果跟预期结果可能有好几秒的偏差。当然,为了精确裁剪,有一个变通的方法是将视频所有帧都先转成I帧再截取,但成本高,耗时大,无法满足实时响应需求。

    所以本文才会选择根据I帧的时间点分割视频,虽然在遍历I帧时会有一些性能损失,但精确度跟总体响应速度都能满足需求。

    分析I帧时间点的命令如下:

    ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 ${videoFile}
    

    关键参数:

    1. skip_frame: 指定跳过那些帧,这里的值是 nokey 也就忽略了除I帧外的情况

    2. select_streams: 选择下标为0的视频流

    3. frame: 输出帧包的 pkt_pts_time 字段

    执行命令,会得到类似下面的输出:

    1. ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 xxx.mp4
    2. 0.000000
    3. 10.000000
    4. 20.000000
    5. 30.000000
    6. 40.000000
    7. 50.000000
    8. 60.000000
    9. 70.000000
    10. 80.000000
    11. 90.000000
    12. 100.000000
    13. 110.000000
    14. 120.000000
    15. 130.000000
    16. 140.000000

    每一行即为关键帧在视频出现的时间点。

    提示:

    在生产环境千万要注意,有时候拿到的视频 I 帧间隔极短,比如笔者就遇到过用 hiki SDK 从网络摄像头或NVR设备取回的视频,平均 0.1s 一个关键帧,所以分割时如果帧间间隔很短,应该考虑将多个关键帧时间点合并成一个切片输出。

    这一步需要调用 child_processexec 函数运行 ffprobe 命令,并将结果格式化成 number 类型方便后续处理,核心代码:

    1. const cmdReadKeyframe = `ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 ${videoFilePath}`;
    2. const keyframes = (await execPromise(cmdReadKeyframe))
    3. .split('\n')
    4. .filter((d) => /^[\d\.]+$/.test(d))
    5. .map((d) => +d);

    3.2 生成M3U8索引文件

    M3U8 文件最主要的作用是提供了HLS视频分片的索引信息,播放器在处理HLS视频时首先访问这个索引文件,找出所有切片视频后再逐一按序播放。M3U8 常见格式如:

    1. #EXTM3U
    2. #EXT-X-PLAYLIST-TYPE:VOD
    3. #EXT-X-TARGETDURATION:10
    4. #EXTINF:10,
    5. /hls_video/20200525221257_20200525222012.mp4?start=0&duration=10
    6. #EXTINF:10,
    7. /hls_video/20200525221257_20200525222012.mp4?start=10&duration=10
    8. #EXTINF:10,
    9. #EXTINF:5.319999999999993,
    10. /hls_video/20200525221257_20200525222012.mp4?start=430&duration=5.319999999999993
    11. #EXT-X-ENDLIST

    简单介绍一下关键字段的含义:

    1. EXT-X-PLAYLIST-TYPE : 播放列表的类型,支持“EVENT”表示直播流,此时服务端可以不断往这个文件追加内容;“VOD”表示静态视频流 ,此时M3U8文件不会发生变更。在我们的场景中,选择使用“VOD”

    2. EXT-X-TARGETDURATION : 最大切片时长

    3. EXTINF :视频切片描述字段,需要指明切片地址、时长

    4. EXT-X-ENDLIST : 视频结束标志符,仅在 EXT-X-PLAYLIST-TYPE=VOID 时有效。

    这一步需要根据上一步得到的关键帧时间点列表拼接出一个完整的 m3u8 文件,核心代码:

    1. // 计算分片大小
    2. // 实际应用中注意判断,当时长小于某个阈值时应该合并多个节点
    3. const fragments = keyframes.map((k, i) => ({
    4.  duration: i === keyframes.length - 1 ? duration - k : keyframes[i + 1] - k,
    5.  start: k
    6. }));
    7. ctx.body =
    8.  // m3u8 头部信息
    9.  '#EXTM3U\n' +
    10.  '#EXT-X-PLAYLIST-TYPE:VOD\n' +
    11.  `#EXT-X-TARGETDURATION:${Math.max(...fragments.map((f) => f.duration))}\n` +
    12.  // 遍历分片,生成分片描述
    13.  `${fragments
    14. .map(
    15. (f) =>
    16. // 这里简单起见,直接用参数记录分片开始、结束时间
    17.    `#EXTINF:${f.duration},\n/hls_video/${videoFile}?start=${f.start}&duration=${f.duration}`
    18. )
    19. .join('\n')}\n` +
    20.  // 结束标志符
    21.  '#EXT-X-ENDLIST';
    22. ctx.type = 'application/x-mpegURL';

    3.3 生成视频片段

    最后,需要根据分片的开始结束时间裁剪视频,毫无疑问这里还是用的 ffempg ,关键命令:

    ffmpeg -ss 10 -i xxx.mp4 -t 10 -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset 10 -
    

    命令参数:

    1. -ss :指定开始时间

    2. -t :指定裁剪时长

    3. -vcodec copy -acodec copy : 指定音视频编码规则,可根据实际场景调整,注意转码时间通常较长,建议尽量复制原始音视频流

    4. -f hls : 指定视频封装格式为 hls

    5. -bsf h264_mp4toannexb :指定字节流过滤器,用于将原始 mp4 封装的字节数据转换为适合 hls 格式的字节数据

    6. -output_ts_offset 10 : 这个很重要,指定分片开始时间,这个值必须严格按照分片在原视频所处的时间进行设定,否则播放器无法正常播放

    提示:

    整行命令的意思就是从 ss 开始截取 -t 长度的片段,音视频流直接copy,转封装为 hls 格式并对每个字节流使用 h264_mp4toannexb 过滤器,完事了再设定输出视频的开始时间。

    对应的核心代码:

    1. const cmd = `-ss ${start} -i ${videoFilePath} -t ${duration} -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset ${start} -`;
    2. const { stdout } = spawn('ffmpeg', cmd.split(' '));
    3. ctx.type = 'video/MP2T';
    4. ctx.body = stdout;

    提示:

    这里还有一个小知识点,命令最后指定的输出是 - ,ffmpeg 遇到这种输出指令时会边转码边将字节流输出到后面的管道上,对应上例中将 spawn 命令的 stdout 流直接赋值给 ctx.body ,从而尽快向客户端发出响应。

    四、性能优化

    视频处理过程非常耗费时间,在本文介绍的这种实时处理场景更需要特别关注性能,可以从尽量提升单个视频处理速度尽量减少视频处理 两个方面入手,下面罗列一些有效的优化手段:

    4.1 使用GPU处理视频

    ffmpeg 默认情况下会用CPU执行命令,CPU运行的缺点一是会大量占用CPU资源,降低系统效率;二是CPU更擅长执行逻辑运算,执行这种视频处理的速度远远比不上GPU。要用GPU跑ffmpeg 命令,必要条件:

    1. 系统带有支持对应视频编码格式的硬件加速功能的显卡

    2. 装好显卡驱动

    3. 编译GPU版本的ffmpeg

    提示:

    环境配置太繁琐了,建议有兴趣的读者在带GPU硬件的机器上跑 ffmpeg 镜像 试试。

    满足上述条件后,只需在原本CPU版本的命令基础上增加一个参数,例如使用NVIDIA显卡加速时 -hwaccel nvdec ,上述命令对应GPU版本:

    ffmpeg -hwaccel nvdec -ss 10 -i xxx.mp4 -t 1000 -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset 10 -
    

    尝试跑了几个视频,性能对比:

    CPU 占用率平均耗时
    CPU250% +2m41s
    GPU (基于T4)75% - 90%55s

    提示:

    测试样本非常小,所以数据并不具有普适性,此处只是为了表达,GPU真的很快。

    4.2 缓存

    第二个优化策略是使用缓存,减少直接处理视频。

    对服务端而言,可以将裁剪后的视频持久化为硬盘文件,下次访问相同参数时直接输出文件。如果视频内容有可能发生变化,建议每次匹配时校验视频MD5值。

    对浏览器端而言则可以启用强缓存,推荐的方案是视频名称以MD5命名,然后通过 max-age 头设置很长的缓存过期时间。

    提示:

    注意尽量避免使用 must-revalidate ,因为每次到服务端验证,服务端还是得执行视频编解码操作,频繁验证性能反而会降低。

    4.3 其他优化策略

    还存在很多优化策略,可以根据实际情况启用,例如:

    • 使用CDN。CDN能够实现一个节点服务一群用户,那么理论上用户A访问某个视频后,同区内用户B/C/D 访问相同视频就可以直接取CDN节点上的副本,避免到源节点访问。

    • 使用H265编码传输。同一个视频,H265编码能比H264节省约1/3的空间,所以H265的网络性能相比之下会好很多。不过问题在于浏览器默认普遍不支持播放H265视频,所以这个方案对客户端要求较高,不具有普适性。

    • 避免视频转码。转码过程需要对视频每一帧执行解码、转码,性能消耗极大,相比视频裁剪、修改码率、修改分辨率、转封装等操作算是小弟了,因此建议在保存视频的时候就直接保存成最后用户使用的编码格式,避免实时转码。

    作者:范文杰 链接:https://juejin.cn/post/6844904180914601991

  • 相关阅读:
    【C++】string类的介绍与使用
    Java虚拟机常见面试题总结
    Git
    pat basic 1049 数列的片段和
    HarmonyOS应用开发-RushOrm体验
    文心一言 vs GPT-4 —— 全面横向比较
    Android.mk 中覆盖应用包名
    电机与拖动 - 8 直流电机的电力拖动
    HTML CSS大学生期末网页大作业 DW个人网页设计 人物介绍 历史人物岳飞介绍
    CTF--攻防世界--杂项基础
  • 原文地址:https://blog.csdn.net/yinshipin007/article/details/128104672