实现
一、认识
柏拉文播放器 基于hlsjs
和web components
,让原生的标签r-player
拥有统一的视频控件。 不采用new Player(options)
的方式挂载到指定dom
,视图的归视图,逻辑的归逻辑,所见及所得,更加直观。
1.1 服务端
-
视频转码
-
生成不同码率的视频
-
进行视频标准加密
-
不同码率视频合并,用于动态码率播放
1.2 Web 端
-
web
端播放器的设计 -
web
端播放器的自定义扩展 -
可拖拽进度条
-
音量控制
-
根据当前带宽自适应码率切换
-
手动清晰度切换
-
倍速播放
-
样式自定义覆盖
-
**
hls
**协议标准加密视频播放 -
基于原生开发,可在所有框架运行,统一跨框架情况
-
各浏览器控件统一
二、方案
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 AVC
或H.264/AVC
)。 -
H.26x
系列:H.261
、H.262
、H.263
、H.264
(等同于MPEG-4
第十部分)、H.265/HEVC
(ITU-T
和ISO/IEC
联合推出)。 -
其它视频编码:
WMV
系列、RV
系列、VC-1
、DivX
、XviD
、X264
、X265
、VP8
、VP9
、Sorenson Video
、AVS
。
-
-
常见的音频编码有:
AAC
,MP3
,AC-3
等
编码之后,还需要将音频和视频合并在一个文件里,这就是封装。
所以相对的,播放一个视频,就需要解封装,解码,音视频同步喂给声卡和显卡进行播放。
MediaSource
做的就是这个工作,读取视频流,转换成浏览器能播放的格式。
2.2 HLS 播放方案
采用 HLS
技术方案,有以下几个原因:
-
兼容性: 上面介绍了各种视频格式,还有浏览器的兼容性, 其中
HLS
协议是Apple
公司实现的,在Apple
的全系列产品包括iPhone
、iPad
、Safari
等都可以原生支持播放HLS
。对于其他浏览器,可以通过MediaSource
解封装,解码,转码,进行播放。这样也就解决了MediaSource
的兼容性问题。 -
业务场景需求: 目前对于视频的加密有着强需求,比如需要用户付费才能观看一些视频。而
HLS
协议天然自带标准加密,同时也能基于HLS
扩展私有加密。 -
HLS
协议自带支持分片传输和动态码率自适应播放 -
有现成的技术方案,
Hls.js
三、服务端
服务端 主要是生成了HLS
协议的视频播放的地址。
3.1 视频转码
选择了采用HLS
协议的播放方式,那么首先需要处理视频,这部分目前是在服务端进行处理。利用ffmpeg
的能力。
如果以后能将ffmpeg
搬上浏览器,且没有性能问题就好了。现在有类似的webassembly
的npm
包,但性能有点小问题
基于 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
有些场景,比如是否覆盖,直接选择是,避免程序卡住
为了自动化执行,这里会用到node
的spawn
模块,创建一个子进程,在子进程中执行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
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
命令,这里同样需要用node
的spawn
模块进行封装成接口:
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
端需要知道如何获取密钥 -
密钥用一次即失效,每次加密视频都生成新的密钥
目前更好的安全性方式为: 在请求密钥的地址上进行加固
-
校验
cookie
,既然是发起请求,那么同域名会自动携带cookie
,只有购买过的用户才能获取密钥。(总不能让付费的用户也不能看吧) -
生成密钥链接时,带上
ticket
,短时间失效,控制时效性 -
请求头携带
auth
,进行用户校验。比如jwt
方案就是如此
3.4 自适应码率播放
**码率(也称为比特率)**是指视频文件在单位时间内使用的数据流量。它反映了视频文件的数据压缩程度,码率越高,压缩比就越小,画面质量就越高,但文件体积也越大。通俗来说,码率可以看作是取样率,是视频编码中画面质量控制中最重要的部分。计算公式是文件体积=时间X码率/8。
码率和清晰度的关系为: 码率越高,清晰度就越好。成正比关系。
需要根据视频的质量,和业务场景,去定义,这里给出阿里云对码率和清晰度的定义,可供参考:
了实现自适应码率播放,我们需要将不同码率的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
。那么就可以根据当前的网速,进行动态切换。