主题
nodejs实现视屏流播放
一. 最终效果展示
常规情况下,若有一个需求是要在某个页面上进行视屏的播放,最先想到的都是用video, 然后将视频地址往src里面一放就完了,但是若一个视屏很大,video直接播放就需要缓冲很久,因为video播放视频都需要先将视频进行下载到内存然后才对视频进行播放,用户体验很糟糕,基于stream的形式就能解决这个问题。
stream【流】的优点很多,最直观的就是文件操作, 服务器不需要直接将文件整个读取到内存, 而是读取一点处理一点,因此对服务器的内存空间的占用先对较少。同样基于stream的形式, 服务器并不会一次将整个视频返回,而是返回一点video就能播放一部分,要播放下一部分在继续请求,体现在我们播放视频的时候,进度条是分层的,会存在一个缓冲层。
我们仔细观察, 一些视频网站,比如 bilibili, 腾讯视频等, 播放视频都是基于这种stream的方式来处理
二. 前置知识Range
range是一个请求头,我们来看一下MDN
对Range
的介绍
Range
是一个请求首部,告知服务器返回文件的哪一部分。 在一个Range
首部中,可以一次性请求多个部分,服务器会以multipart
文件的形式将其返回。 如果服务器返回的是范围响应,需要使用206
Partial Content 状态码。 假如所请求的范围不合法,那么服务器会返回416
Range Not Satisfiable 状态码,表示客户端错误。 服务器允许忽略Range
首部,从而返回整个文件,状态码用200
。
也就是说,只要客户端发请求的时候带上range
请求头,服务端就知道这是需要分片段返回的,然后返回对应片段,当然也可以返回整个文件。具体看服务端怎么处理。
range示例
html
Range: bytes=200-1000, 2000-6576, 19000-
或
Range: bytes=0-
bytes
表示范围所采用的单位,通常是字节(bytes) =
后面的数值是一个整数,表示在特定单位下,范围的起始值 -
右边的是一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。
刚好video
请求视频的请求就带有Range
这个请求头
三. nodejs实现
1. 基于原生Nodejs
1.1 搭建服务
js
// app.js
const http = require('http');
const fs = require('fs');
http.createServer(async (req, res) => {
if (req.url === '/') {
// 处理返回video播放器
res.writeHead(200, {'Content-Type': 'text/html'})
res.end(`<video src='/video' width="500px" height="300px" controls></video>`)
} else if (req.url === '/video') {
// 处理返回视频流
fs.createReadStream('./ideo.mp4, { start: start, end: end }).pipe(ctx.res)
} else {
// 处理返回ico网页icon
res.writeHead(200, {'content-type': 'image/x-icon'})
fs.createReadStream('./favicon.ico').pipe(res)
}
}).listen(4001)
上面的服务简单的用if else来分配路由, 因为我们也只用得到两个路由, 一个是/
,请求video播放器的,另一个是video请求视频的接口。 当node app.js
启动服务后,访问http://127.0.0.1:4001
,可以看到一个视频播放器,饭后视频也能正常播放
1.2. 流式播放改造
在video
这个路由里面,增加range解析判断的代码
js
// 获取请求头
const range = req.headers['range']
// 若有range请求头,则做流式分段返回
if (range) {
const fileStats = await stat('./video.mp4')
const [, chunk] = range.split('=');
let [startLen, endLen] = (chunk.split('/')[0]).split('-');
startLen = parseInt(startLen)
endLen = endLen ? parseInt(endLen) : startLen + 100 * 1024;
if (endLen > fileStats.size - 1) endLen = fileStats.size - 1;
console.log(startLen, '----', endLen, '-----', fileStats.size);
let head = {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${startLen}-${endLen}/${fileStats.size}`,
'Content-Length': endLen - startLen + 1,
'Accept-Ranges': 'bytes'
}
res.writeHead(206, head);
fs.createReadStream('./video.mp4', { start: startLen, end: endLen }).pipe(res)
} else {
// 否则返回整个文件
res.writeHead(200, {
'content-type': 'video/mp4'
})
fs.createReadStream('./video.mp4').pipe(res)
}
1.3. 最终完整代码
js
const http = require('http');
const fs = require('fs');
const stat = promisify(fs.stat);
http.createServer(async (req, res) => {
if (req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html'
})
res.end(`
<video src='/video' width="500px" height="300px" controls></video>
`)
} else if (req.url === '/video') {
const range = req.headers['range']
if (range) {
const fileStats = await stat('./access/video.mp4')
const [, chunk] = range.split('=');
let [startLen, endLen] = (chunk.split('/')[0]).split('-');
startLen = parseInt(startLen)
endLen = endLen ? parseInt(endLen) : startLen + 100 * 1024;
if (endLen > fileStats.size - 1) endLen = fileStats.size - 1;
console.log(startLen, '----', endLen, '-----', fileStats.size);
let head = {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${startLen}-${endLen}/${fileStats.size}`,
'Content-Length': endLen - startLen + 1,
'Accept-Ranges': 'bytes'
}
res.writeHead(206, head);
fs.createReadStream('./access/video.mp4', { start: startLen, end: endLen }).pipe(res)
} else {
res.writeHead(200, {
'content-type': 'video/mp4'
})
fs.createReadStream('./access/video.mp4').pipe(res)
}
} else {
res.writeHead(200, {
'content-type': 'image/x-icon'
})
fs.createReadStream('./access/favicon.ico').pipe(res)
}
}).listen(4001)
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn(...args, function (err, data) {
if (err) reject();
resolve(data);
})
});
}
}
2. 基于koa实现
2.1. koa服务
js
// serve.js
const Koa = require('koa');
const Router = require('@koa/router');
const path = require('path');
const fs = require('fs')
const statics = require('koa-static');
const stat = promisify(fs.stat)
// const sourceUrl = 'https://baobao-article.cdn.bcebos.com/vj9hntdare1ts5427mm46zyrnj.mp4';
const videoPath = './access/video.mp4'
const app = new Koa();
const router = new Router();
router.get('/video', async ctx => {
const range = ctx.req.headers['range']
console.log(range);
if (range) {
const stats = await stat(videoPath)
const r = range.match(/=(\d+)-(\d+)?/);
const start = parseInt(r[1]);
const end = r[2] ? parseInt(r[2]) : start + 100 * 1024; // 默认返回500kb
console.log(start, "-----", end, "--------", stats.size);
if (end > stats.size - 1) end = stats.size - 1;
let head = {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Content-Length': end - start + 1,
'Accept-Ranges': 'bytes'
}
ctx.res.writeHead(206, head)
fs.createReadStream(videoPath, { start: start, end: end }).pipe(ctx.res)
ctx.res.end()
} else {
res.writeHead(200, {
'content-type': 'video/mp4'
})
fs.createReadStream(videoPath).pipe(ctx.res)
ctx.res.end()
}
});
app.use(statics(
path.join(__dirname, 'access')
))
app.use(router.routes())
app.use(router.allowedMethods());
app.listen(3000);
console.log('port :3000');
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn(...args, function (err, data) {
if (err) reject();
resolve(data);
})
});
}
}
2.2. html代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<video
src="http://127.0.0.1:3000/video"
width="500px" height="300px"
id="video"
controls>
</video>
</body>
</html>