web视频播放浅析

  目录

简单的介绍html中video标签的几种使用场景,注意,视频播放是点播形式。

视频知识

web端以前用flash播放视频,但是由于安全原因,现在已经抛弃,使用html5的video标签。

视频容器(也称为视频封装格式)是用来存储音频、视频和其他元数据(如字幕、章节信息等)的一种文件格式结构。它将音视频编码数据组织在一起,以便于播放器解析和呈现。容器的作用就像一个“包装盒”,将不同的轨道(视频流、音频流、字幕流等)按照一定的标准组织起来,使得各部分数据可以同步播放。

常见的视频文件格式

img

常见的视频编码格式

img

浏览器中的视频格式

WebM

img

Ogg/Theora

img

MPEG-4/H.264

img

常见视频播放方法

基础方式

使用video标签,src直接链接到一个完整的视频地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video-basic</title>
<style>
#video {
width: 800px;
margin: 20px auto;
display: block;
}
</style>
</head>
<body>
<div id="app">
<video id="video" controls autoplay>
<source src="http://127.0.0.1:3000/video1.mp4" type="">
</video>
</div>
</body>
</html>

这个例子是最基础最简单的。
源码

video-stream方式

前端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Streaming</title>
<style>
body {
background-color: #000000;
}
video {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
max-height: 100%;
max-width: 100%;
margin: auto;
object-fit: contain;
}
</style>
</head>
<body>
<video
src="http://localhost:3000/api/video/video.mp4"
playsInline
muted
autoplay
controls
controlsList="nodownload"
>
</video>
</body>
</html>

后端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import Koa from 'koa'
import KoaRouter from 'koa-router'
import sendFile from 'koa-sendfile'
import url from 'url'
import path from 'path'
import fs from 'fs'
import util from 'util'

const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const PORT = parseInt(process.env.PORT, 10) || 3000
const app = new Koa()
const router = new KoaRouter()

//
// Serve HTML page containing the video player
//
router.get('/', async (ctx) => {
await sendFile(ctx, path.resolve(__dirname, 'public', 'index.html'))

if (!ctx.status) {
ctx.throw(404)
}
})

//
// Serve video streaming
//
router.get('/api/video/:name', async (ctx, next) => {
const { name } = ctx.params

if (
!/^[a-z0-9-_ ]+\.mp4$/i.test(name)
) {
return next()
}

const { request, response } = ctx
const { range } = request.headers

if (!range) {
ctx.throw(400, 'Range not provided')
}

const videoPath = path.resolve(__dirname, 'videos', name)

try {
await util.promisify(fs.access)(videoPath)
} catch (err) {
if (err.code === 'ENOENT') {
ctx.throw(404)
} else {
ctx.throw(err.toString())
}
}

//
// Calculate start Content-Range
//
const parts = range.replace('bytes=', '').split('-')
const rangeStart = parts[0] && parts[0].trim()
const start = rangeStart ? parseInt(rangeStart, 10) : 0

//
// Calculate video size and chunk size
//
const videoStat = await util.promisify(fs.stat)(videoPath)
const videoSize = videoStat.size
const chunkSize = 10 ** 6 // 1mb

//
// Calculate end Content-Range
//
// Safari/iOS first sends a request with bytes=0-1 range HTTP header
// probably to find out if the server supports byte ranges
//
const rangeEnd = parts[1] && parts[1].trim()
const __rangeEnd = rangeEnd ? parseInt(rangeEnd, 10) : undefined
const end = __rangeEnd === 1 ? __rangeEnd : (Math.min(start + chunkSize, videoSize) - 1) // We remove 1 byte because start and end start from 0
const contentLength = end - start + 1 // We add 1 byte because start and end start from 0

response.set('Content-Range', `bytes ${start}-${end}/${videoSize}`)
response.set('Accept-Ranges', 'bytes')
response.set('Content-Length', contentLength)

const stream = fs.createReadStream(videoPath, { start, end })
stream.on('error', (err) => {
console.log(err.toString())
})

response.status = 206
response.type = path.extname(name)
response.body = stream
})

//
// We ignore ECONNRESET, ECANCELED and ECONNABORTED errors
// because when the browser closes the connection, the server
// tries to read the stream. So, the server says that it cannot
// read a closed stream.
//
app.on('error', (err) => {
if (!['ECONNRESET', 'ECANCELED', 'ECONNABORTED'].includes(err.code)) {
console.log(err.toString())
}
})

//
// Add Koa Router middleware
//
app.use(router.routes())
app.use(router.allowedMethods())

//
// Start the server on the specified PORT
//
app.listen(PORT)
console.log('Video Streaming Server is running on Port', PORT)

可以看出,前端部分的代码跟第一个例子一样,直接给video标签的src一个视频链接地址,但是这个地址指向后台一个api接口。
video标签默认会在请求头加上Content-Range属性,后端接口会根据这个属性去返回对应的数据。对于前端开发来说是无感的,因为浏览器已经自动完成了,只需要对后端接口做处理即可。
源码

mediaSource方式

这个方式用到了MediaSource这个API,允许JavaScript创建和处理媒体数据源,使得浏览器能够播放来自各种来源的自定义媒体数据,而不是只限于原始的文件或 URL。
前端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<video id="video" width="800" height="400" webkit-playsinline="true" controls playsinline="true" type="video/mp4" muted x5-video-player-type="h5" >
<script>
var PostbirdMp4ToBlob = {
mediaSource:new MediaSource(),
// 检查是否支持 MediaSource 或者 mimeCodec
checkSupported: function (cb) {
if ('MediaSource' in window && MediaSource.isTypeSupported(this.mimeCodec)) {

} else {
this.video.src = assetUrl; // 如果不支持,则直接将 src 修改成原始的url,保证兼容性
console.error('Unsupported MediaSource or unsupported MIME type or codec: ', this.mimeCodec);
}
},
// 初始化 selector / assetUrl / mimeCodec / autoPlay
// selector:video的选择器 exp: '#video'
// assetUrl: video的请求地址 exp : './v.mp4'
// mimeCodec: 编码模式 exp: 'video/mp4; codecs="avc1.640028, mp4a.40.2"'
init: function (selector, assetUrl, mimeCodec) {
this.video = document.querySelector(selector); // 获取vide dom
this.assetUrl = assetUrl;
this.mimeCodec = mimeCodec;
this.checkSupported();
this.start();// 开启
},
start: function () {
console.log(this.mediaSource.readyState); // closed
this.video.src = URL.createObjectURL(this.mediaSource);
this.mediaSource.addEventListener('sourceopen', this.sourceOpen.bind(this));// bind(this) 保证回调
},
// MediaSource sourceopen 事件处理
sourceOpen: function (_) {
var _this = this;
console.log(this.mediaSource.readyState); // open
var sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeCodec);
this.fetchAB(this.assetUrl, function (buf) {
sourceBuffer.addEventListener('updateend', function (_) {
_this.mediaSource.endOfStream();// 结束
_this.video.play(); // 播放视频
console.log(_this.mediaSource.readyState); // ended
});
sourceBuffer.appendBuffer(buf);
});
},
// 基于 XHR 的简单封装
// arguments - url
// arguments - cb (回调函数)
fetchAB: function (url, cb) {
var xhr = new XMLHttpRequest;
xhr.open('get', url);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
cb(xhr.response);
};
xhr.send();
}
};
</script>
<script>
var codec = 'video/mp4; codecs="avc1.42e01e, mp4a.40.2"';
PostbirdMp4ToBlob.init('#video', '/video/video1.mp4', codec);
</script>
</body>
</html>

后端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs').promises;

const app = new Koa();


const router = new Router();

app.use(static('./static'));
app.use(router.routes());

app.listen(8080, () => {
console.log('server is run at 8080......');
})

后端是一个静态资源服务器

这里需要注意下,用MediaSource接口来播放视频,对视频文件是有要求的,目前例子中的mp4文件可以播放,但是笔者试了很多方式来转换其它的视频文件都没有成功,目前也在尝试中。

源码

m3u8 Hls方式

.m3u8文件本质上是一个索引文件,其中包含了指向一系列多媒体片段(通常是经过加密或未加密的TS格式文件)的URL列表。
前端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video-m3u8</title>
</head>
<body>
<div id="app">
<div class="video-list">
<div>播放列表</div>
<ul></ul>
</div>
<video id="video" controls autoplay></video>
</div>
<script type="module" src="/main.js"></script>
</body>
</html>

// main.js
import './style.css';
import Hls from 'hls.js';
var video = document.getElementById('video');
var videoList = ['video1', 'video2', 'video3', 'video4', 'video5'];
// 创建buttons并插入dom
var nUl = document.querySelector('.video-list ul');
videoList.forEach(item=> {
var nLi= document.createElement('li');
var nBtn= document.createElement('button');
nBtn.textContent = item;
nLi.appendChild(nBtn);
nUl.appendChild(nLi);
});

playVideo(videoList[0]);

// 按钮添加事件
var buttons = document.querySelectorAll('.video-list button');
for(let btn of buttons) {
btn.onclick = (evt)=> {
let videoName = evt.target.textContent;
playVideo(videoName);
}
}
// 播放video
function playVideo(videoName) {
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource('http://127.0.0.1:3000/'+videoName+'.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
// video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = 'http://127.0.0.1:3000/'+videoName+'.m3u8';
video.addEventListener('loadedmetadata', function () {
// video.play();
});
}
}

js文件中需要hls.js库。

hls.js 是一个开源的JavaScript库,专为解决在现代Web浏览器上播放基于HTTP Live Streaming (HLS)协议的视频内容而设计。HLS协议由苹果公司开发,被广泛用于在网络上分发实时和点播视频内容,尤其适用于移动端和桌面设备。
HLS协议的工作原理是将长视频文件切割成一系列小的媒体片段(通常为MPEG-2 Transport Stream格式,扩展名为.ts),并通过一个M3U8索引文件来组织这些片段。M3U8文件包含了指向各个视频片段的URL以及元数据,如分辨率、编码类型等信息。

video.src = 'http://127.0.0.1:3000/'+videoName+'.m3u8'这段代码是指向一个.m3u8的文件,这个文件怎么生成的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// creatM3u8.js
const fs = require('fs');
const fluentFFmpeg = require('fluent-ffmpeg');
const path = require('path');
// 输入文件夹
const inputFolder = path.resolve(__dirname, './sourceVideos');
// 输入文件夹下的所有视频文件list
const files = fs.readdirSync(inputFolder);

// 输出目录,用于保存生成的 TS 文件和 M3U8 索引文件
const outputDir = path.resolve(__dirname, './public');
// FfmpegCommand实例变量
let command;
// 删除文件夹内容
deleteFolderRecursiveSync(outputDir);
// 循环files文件列表,逐个转换
(async ()=> {
for(let file of files) {
// 创建一个 FfmpegCommand 实例
command = fluentFFmpeg();
await creatVideos(file);
console.log('-----完成转换'+file+'-----');
}
})();

function creatVideos(file) {
return new Promise((resolve, reject )=> {
console.log('-----开始转换'+file+'-----');
const basenameWithExt = path.basename(file);
const extname = path.extname(basenameWithExt);
const fileNameWithoutExt = basenameWithExt.slice(0, -extname.length);
// 设置输出目录和 HLS 参数
command.input(`${inputFolder}/${file}`)
.output(`${outputDir}/${fileNameWithoutExt}.m3u8`)
.outputOptions([
'-hls_time 10', // 每个切片时长为 10 秒
'-hls_list_size 0', // 不限制播放列表的大小(无限循环)
`-hls_segment_filename ${outputDir}/${file}_%03d.ts`, // 指定切片文件命名规则
]);

// 执行转换过程
command
.on('progress', function(progress) {
let per = parseInt(progress.percent || 0);
console.log('转换进度: ' + per + '%');
})
.on('end', () => {
// console.log('HLS 转换完成!');
resolve(file);
})
.on('error', (err) => {
console.error('An error occurred: ' + err.message);
})
.run();
});
}
// 删除文件夹内容
function deleteFolderRecursiveSync(folderPath) {
if (!fs.existsSync(folderPath)) return;

const files = fs.readdirSync(folderPath);

for (let i = 0; i < files.length; i++) {
const filePath = path.join(folderPath, files[i]);

if (fs.lstatSync(filePath).isDirectory()) {
// 如果是子目录,则递归删除
deleteFolderRecursiveSync(filePath);
} else {
// 如果是文件,则直接删除
fs.unlinkSync(filePath);
}
}
}

这个文件读取目录sourceVideos文件夹下的mp4文件,并且分成n个视频片段和.m3u8文件,保存到了public目录下。
public目录是一个静态资源服务器文件夹,前端hls.js库会拿到m3u8文件,解析文件内容,加载视频片段并播放。
源码

总结

以上简单介绍并列举了几种video标签在web视频播放中的例子。