Skip to content

nodejs实现视屏流播放

一. 最终效果展示

image.png

常规情况下,若有一个需求是要在某个页面上进行视屏的播放,最先想到的都是用video, 然后将视频地址往src里面一放就完了,但是若一个视屏很大,video直接播放就需要缓冲很久,因为video播放视频都需要先将视频进行下载到内存然后才对视频进行播放,用户体验很糟糕,基于stream的形式就能解决这个问题。

stream【流】的优点很多,最直观的就是文件操作, 服务器不需要直接将文件整个读取到内存, 而是读取一点处理一点,因此对服务器的内存空间的占用先对较少。同样基于stream的形式, 服务器并不会一次将整个视频返回,而是返回一点video就能播放一部分,要播放下一部分在继续请求,体现在我们播放视频的时候,进度条是分层的,会存在一个缓冲层。

我们仔细观察, 一些视频网站,比如 bilibili, 腾讯视频等, 播放视频都是基于这种stream的方式来处理

huanchong

二. 前置知识Range

range是一个请求头,我们来看一下MDNRange的介绍

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>