跳到主要内容

实现

2024年03月13日
柏拉文
越努力,越幸运

一、认识


柏拉文播放器 基于hlsjsweb components,让原生的标签r-player拥有统一的视频控件。 不采用new Player(options)的方式挂载到指定dom,视图的归视图,逻辑的归逻辑,所见及所得,更加直观。

1.1 服务端

  1. 视频转码

  2. 生成不同码率的视频

  3. 进行视频标准加密

  4. 不同码率视频合并,用于动态码率播放

1.2 Web 端

  1. web端播放器的设计

  2. web端播放器的自定义扩展

  3. 可拖拽进度条

  4. 音量控制

  5. 根据当前带宽自适应码率切换

  6. 手动清晰度切换

  7. 倍速播放

  8. 样式自定义覆盖

  9. **hls**协议标准加密视频播放

  10. 基于原生开发,可在所有框架运行,统一跨框架情况

  11. 各浏览器控件统一

二、方案


2.1 MediaSource和视频编码,解码,封装

web上播放音视频其实限制还是很大的。如何解决这些限制,就会用到 MediaSource。视频其实是无数个图片的叠加,如果视频是一秒60帧,大约一秒中需要播放60张图片。这就导致一个几分钟的视频,就会非常大。比如上面介绍的无损格式,avi格式,每分钟视频大约 2-3 GB。这时候视频就需要进行编码。其实就是压缩

编码分为视频编码音频编码

  • 常见的视频编码有:

    • MPEG系列: MPEG-1第二部分、MPEG-2第二部分(等同于H.262)、MPEG-4第二部分、MPEG-4第十部分(等同于H.264,有时候也被叫做MPEG-4 AVCH.264/AVC)。

    • H.26x系列: H.261H.262H.263H.264(等同于MPEG-4第十部分)、H.265/HEVCITU-TISO/IEC联合推出)。

    • 其它视频编码: WMV系列、RV系列、VC-1DivXXviDX264X265VP8VP9Sorenson VideoAVS

  • 常见的音频编码有: AACMP3AC-3

编码之后,还需要将音频和视频合并在一个文件里,这就是封装

所以相对的,播放一个视频,就需要解封装解码音视频同步喂给声卡和显卡进行播放。

MediaSource 做的就是这个工作,读取视频流,转换成浏览器能播放的格式。

2.2 HLS 播放方案

采用 HLS 技术方案,有以下几个原因:

  1. 兼容性: 上面介绍了各种视频格式,还有浏览器的兼容性, 其中 HLS 协议是Apple公司实现的,在 Apple 的全系列产品包括 iPhoneiPadSafari 等都可以原生支持播放 HLS。对于其他浏览器,可以通过MediaSource解封装,解码,转码,进行播放。这样也就解决了MediaSource的兼容性问题。

  2. 业务场景需求: 目前对于视频的加密有着强需求,比如需要用户付费才能观看一些视频。而HLS协议天然自带标准加密,同时也能基于HLS扩展私有加密。

  3. HLS 协议自带支持分片传输和动态码率自适应播放

  4. 有现成的技术方案,Hls.js

三、服务端


服务端 主要是生成了HLS协议的视频播放的地址。

3.1 视频转码

选择了采用HLS协议的播放方式,那么首先需要处理视频,这部分目前是在服务端进行处理。利用ffmpeg的能力。

如果以后能将ffmpeg搬上浏览器,且没有性能问题就好了。现在有类似的webassemblynpm包,但性能有点小问题

基于 ffmpeg 视频的转码命令如下:

ffmpeg -i input.mp4 -hls_time 10 -hls_list_size 0 -c:v h264 -b:v 2M -hls_segment_filename output_%05d.ts output.m3u8 -y
  • -i: 指定输入的视频

  • -hls_time: 指定分片的时间,单位是秒

  • -hls_list_size: 指定hls列表的数量,这里不限制

  • -c:v: 指定视频的编码格式

  • -b:v: 指定视频的码率,这里是2M比特率

  • -hls_segment_filename: 指定输出的ts文件名字,这里表示是output_ + 五位数字

  • output.m3u8指定输出m3u8文件的名字

  • -y有些场景,比如是否覆盖,直接选择是,避免程序卡住

为了自动化执行,这里会用到nodespawn模块,创建一个子进程,在子进程中执行ffmpeg的命令。

const exec = ({ params, data }: ExecOption): Promise<ExecResult> => {
return new Promise((r, j) => {
const cp = spawn('ffmpeg', params);
cp.stderr.pipe(process.stdout);
cp.on('error', (err) => {
j(err);
});
cp.on('close', (code) => {
r({ code, data });
});
cp.on('exit', (code) => {
r({ code, data });
});
});
};

这时候,视频就会在指定的位置输出了,会生成一个m3u8和多个ts

Preview

ts是视频文件,m3u8更像是索引文件,用来描述ts,比如在什么时间,播放什么ts。主要内容如下:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.380622,
5_00000.ts
#EXTINF:10.380622,
5_00001.ts
#EXTINF:10.380622,
5_00002.ts
#EXTINF:10.380622,
5_00003.ts
#EXTINF:6.560556,
5_00004.ts
#EXTINF:1.619378,
5_00005.ts
#EXTINF:5.024189,
5_00006.ts
#EXT-X-ENDLIST

3.2 视频标准加密

**HLS**协议标准加密采用的是AES对称加密方案。先来实现一个最标准的加密: 首先通过node原生模块crypto生成加密密钥:

import crypto from 'node:crypto';
// 生成加密密钥
const key = crypto
.createHash('sha256')
.update(crypto.randomBytes(32))
.digest('base64');
const filePathKey = path.join(__dirname,`../../public/uploads/hls/${dir}/${fileName}.key`);

const content = `${ctx.origin}/uploads/hls/${dir}/${fileName}.key\n${filePathKey}\n`;
// 密钥的文件
const fileKey = await writeFile(path.join(__dirname, `../../public/uploads/hls/${dir}/${fileName}.key`),key);
const keyInfoPath = path.join(__dirname,`../../public/uploads/hls/${dir}/${fileName}_key.bin`);
// ffmpeg 需要的key.info
const keyInfo = await writeFile(keyInfoPath, content);

然后再执行ffmpeg命令,这里同样需要用nodespawn模块进行封装成接口:

ffmpeg -i input.mp4 -hls_time 10 -hls_list_size 0 -c:v h264 -b:v 2M -hls_key_info_file keyInfoPath -hls_segment_filename output_%05d.ts output.m3u8 -y

主要就增加了一个hls_key_info_file参数,表示加密密钥的地址。这时候,生成的m3u8文件就发生了变化,多了一行:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="http://localhost:30103/uploads/hls/5_1701577743851/5.key",IV=0x00000000000000000000000000000000
#EXTINF:10.380622,
5_00000.ts
#EXTINF:10.380622,
5_00001.ts
#EXTINF:10.380622,
5_00002.ts
#EXTINF:10.380622,
5_00003.ts
#EXTINF:6.560556,
5_00004.ts
#EXTINF:1.619378,
5_00005.ts
#EXTINF:5.024189,
5_00006.ts
#EXT-X-ENDLIST

多了

#EXT-X-KEY:METHOD=AES-128,URI="http://localhost:30103/uploads/hls/5_1701577743851/5.key",IV=0x00000000000000000000000000000000
  • METHOD: 字段表示加密方式,这里是AES

  • URI: 表示密钥地址,这里是http://localhost:30103/uploads/hls/5_1701577743851/5.key

  • IV: 是加密解密时的偏移量,现在是0

上述加密方式,虽然视频确实加密了,但会把密钥地址写在m3u8里。等于把房间上锁,然后在锁上贴一个纸条,上面写了密码。

3.3 视频安全加解密

有加密,必然需要解密。首先我们知道,视频要在web端进行播放,那么无论如何,都肯定需要先解密,再播放。

  • 肯定不能在web端放置密钥

  • web端需要知道如何获取密钥

  • 密钥用一次即失效,每次加密视频都生成新的密钥

目前更好的安全性方式为: 在请求密钥的地址上进行加固

Preview
  1. 校验cookie,既然是发起请求,那么同域名会自动携带cookie,只有购买过的用户才能获取密钥。(总不能让付费的用户也不能看吧)

  2. 生成密钥链接时,带上ticket,短时间失效,控制时效性

  3. 请求头携带auth,进行用户校验。比如jwt方案就是如此

3.4 自适应码率播放

**码率(也称为比特率)**是指视频文件在单位时间内使用的数据流量。它反映了视频文件的数据压缩程度,码率越高,压缩比就越小,画面质量就越高,但文件体积也越大。通俗来说,码率可以看作是取样率,是视频编码中画面质量控制中最重要的部分。计算公式是文件体积=时间X码率/8。

码率清晰度的关系为: 码率越高,清晰度就越好。成正比关系。

需要根据视频的质量,和业务场景,去定义,这里给出阿里云对码率和清晰度的定义,可供参考:

Preview

了实现自适应码率播放,我们需要将不同码率的m3u8合并成一个多码率的m3u8。这里我没找到合适的ffmpeg命令,但总有办法的,最万能的方法就是,查看HLS协议中多码率的m3u8格式标准,自己写一个。

目前用node去写一个这样的索引文件,具体内容如下:

#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720"
hls/5_1701577771368/5.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=50000,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=320x184,NAME="320"
hls/5_1701577744714/5.m3u8
  • #EXT-X-STREAM-INF: 流媒体的描述

  • PROGRAM-ID: 表示唯一的ID

  • BANDWIDTH: 流媒体的带宽,即每秒传输的数据量。这里带宽为50000,意味着每秒传输的数据量大约为50kbps

  • CODECS: 流媒体使用的编解码器。这里是使用了mp4a.40.5(AAC音频编码)和avc1.42000d(AVC视频编码)。

  • RESOLUTION: 这个字段指示了视频的分辨率,即宽度和高度。在这个例子中,视频分辨率为320x184

  • NAME: 这个字段为流媒体提供了一个名称,本例中名称为320。我会习惯把清晰度放在NAME这个字段这里,方便web端获取

实现一个接口,传入以上的参数,动态拼接字符串,写入文件

async generateMasterPlayList(ctx: Context): Promise<void> {
try {
const { paths, filename = Date.now() } = ctx.request.body;
let content = `#EXTM3U\n`;
paths.forEach((item: MasterPlayListOption, index: number) => {
const { id = index, bandWidth, codecs, resolution, name, url } = item;
content += `#EXT-X-STREAM-INF:PROGRAM-ID=${id},BANDWIDTH=${bandWidth},CODECS="${codecs}",RESOLUTION=${resolution},NAME="${name}"\n${url}\n`;
});
const dir = path.join(__dirname, `../../public/uploads/hls/`);
if (!existsSync(dir)) {
await createDir(dir);
}
const filePath = dir + (filename.toString().endsWith('.m3u8') ? filename : `${filename}.m3u8`)
const { success, error } = await writeFile(filePath, content);
const basename = path.basename(filePath);
return success
? ctx.successHandler({
url: `${ctx.origin}/uploads/hls/${basename}`,
})
: ctx.failHandler(error);
} catch (error) {
ctx.errorHandler(error);
}
}

那么HLS是如何自适应码率的呢?其实是根据BANDWIDTH这个字段,因为我们给不同的视频设置了不同的BANDWIDTH。那么就可以根据当前的网速,进行动态切换。

四、Web 端