使用 Nuxt.js、Node 和 Express 构建视频流应用
已发表: 2022-03-10视频与流一起使用。 这意味着不是一次发送整个视频,而是将视频作为一组组成完整视频的较小块发送。 这解释了为什么在慢速宽带上观看视频时视频会缓冲,因为它只播放它收到的块并尝试加载更多。
本文适用于愿意通过构建实际项目来学习新技术的开发人员:一个以 Node.js 为后端、Nuxt.js 为客户端的视频流应用程序。
- Node.js是用于构建快速且可扩展的应用程序的运行时。 我们将使用它来处理获取和流式传输视频、为视频生成缩略图以及为视频提供字幕和字幕。
- Nuxt.js是一个 Vue.js 框架,可以帮助我们轻松构建服务器渲染的 Vue.js 应用程序。 我们将为视频使用我们的 API,此应用程序将有两个视图:可用视频列表和每个视频的播放器视图。
先决条件
- 了解 HTML、CSS、JavaScript、Node/Express 和 Vue。
- 文本编辑器(例如 VS Code)。
- 网络浏览器(例如 Chrome、Firefox)。
- FFmpeg 安装在您的工作站上。
- 节点.js。 非易失性。
- 您可以在 GitHub 上获取源代码。
设置我们的应用程序
在此应用程序中,我们将构建路由以从前端发出请求:
-
videos
路由以获取视频列表及其数据。 - 从我们的视频列表中仅获取一个视频的路线。
-
streaming
视频的流媒体路由。 -
captions
路由为我们正在流式传输的视频添加字幕。
创建路由后,我们将Nuxt
前端,我们将在其中创建Home
和动态player
页面。 然后我们请求我们的videos
路由用视频数据填充主页,另一个请求在我们的player
页面上流式传输视频,最后一个请求提供视频使用的字幕文件。
为了设置我们的应用程序,我们创建我们的项目目录,
mkdir streaming-app
设置我们的服务器
在我们的streaming-app
目录中,我们创建了一个名为backend
的文件夹。
cd streaming-app mkdir backend
在我们的后端文件夹中,我们初始化一个package.json
文件来存储有关我们的服务器项目的信息。
cd backend npm init -y
我们需要安装以下软件包来构建我们的应用程序。
- 当我们进行更改时,
nodemon
自动重新启动我们的服务器。 -
express
为我们提供了一个很好的接口来处理路由。 -
cors
将允许我们发出跨域请求,因为我们的客户端和服务器将在不同的端口上运行。
在我们的后端目录中,我们创建了一个文件夹assets
来保存我们的流媒体视频。
mkdir assets
将.mp4
文件复制到 assets 文件夹中,并将其命名为video1
。 您可以使用可以在 Github Repo 上找到的.mp4
简短示例视频。
创建一个app.js
文件并为我们的应用添加必要的包。
const express = require('express'); const fs = require('fs'); const cors = require('cors'); const path = require('path'); const app = express(); app.use(cors())
fs
模块用于在我们的服务器上轻松读取和写入文件,而path
模块提供了一种处理目录和文件路径的方法。
现在我们创建一个./video
路由。 当请求时,它会将视频文件发送回客户端。
// add after 'const app = express();' app.get('/video', (req, res) => { res.sendFile('assets/video1.mp4', { root: __dirname }); });
此路由在请求时提供video1.mp4
视频文件。 然后我们在端口3000
监听我们的服务器。
// add to end of app.js file app.listen(5000, () => { console.log('Listening on port 5000!') });
在package.json
文件中添加了一个脚本来使用 nodemon 启动我们的服务器。
"scripts": { "start": "nodemon app.js" },
然后在你的终端上运行:
npm run start
如果您看到消息Listening on port 3000!
在终端中,则服务器正常工作。 在浏览器中导航到 https://localhost:5000/video,您应该会看到正在播放的视频。
由前端处理的请求
以下是我们将从前端向后端发出的请求,我们需要服务器来处理这些请求。
-
/videos
返回一组视频模型数据,这些数据将用于填充我们前端Home
上的视频列表。 -
/video/:id/data
返回单个视频的元数据。 由我们前端的Player
页面使用。 -
/video/:id
流式传输具有给定 ID 的视频。 由Player
页面使用。
让我们创建路线。
返回视频列表的模型数据
对于这个演示应用程序,我们将创建一个对象数组来保存元数据并在请求时将其发送到前端。 在真正的应用程序中,您可能会从数据库中读取数据,然后将其用于生成这样的数组。 为简单起见,我们不会在本教程中这样做。
在我们的后端文件夹中创建一个文件mockdata.js
并使用我们的视频列表的元数据填充它。
const allVideos = [ { id: "tom and jerry", poster: 'https://image.tmdb.org/t/p/w500/fev8UFNFFYsD5q7AcYS8LyTzqwl.jpg', duration: '3 mins', name: 'Tom & Jerry' }, { id: "soul", poster: 'https://image.tmdb.org/t/p/w500/kf456ZqeC45XTvo6W9pW5clYKfQ.jpg', duration: '4 mins', name: 'Soul' }, { id: "outside the wire", poster: 'https://image.tmdb.org/t/p/w500/lOSdUkGQmbAl5JQ3QoHqBZUbZhC.jpg', duration: '2 mins', name: 'Outside the wire' }, ]; module.exports = allVideos
从上面我们可以看到,每个对象都包含有关视频的信息。 注意包含指向视频海报图像的链接的poster
属性。
让我们创建一个videos
路由,因为前端发出的所有请求都以/videos
开头。
为此,让我们创建一个routes
文件夹并为我们的/videos
路由添加一个Video.js
文件。 在这个文件中,我们需要express
并使用 express 路由器来创建我们的路由。
const express = require('express') const router = express.Router()
当我们转到/videos
路由时,我们想要获取我们的视频列表,所以让我们将mockData.js
文件添加到我们的Video.js
文件中并发出我们的请求。
const express = require('express') const router = express.Router() const videos = require('../mockData') // get list of videos router.get('/', (req,res)=>{ res.json(videos) }) module.exports = router;
现在声明/videos
路由,保存文件,它应该会自动重新启动服务器。 启动后,导航到 https://localhost:3000/videos,我们的数组以 JSON 格式返回。
返回单个视频的数据
我们希望能够在我们的视频列表中请求特定视频。 我们可以使用我们提供的id
来获取数组中的特定视频数据。 让我们发出一个请求,仍然在我们的Video.js
文件中。
// make request for a particular video router.get('/:id/data', (req,res)=> { const id = parseInt(req.params.id, 10) res.json(videos[id]) })
上面的代码从路由参数中获取id
并将其转换为整数。 然后我们将与videos
数组中的id
匹配的对象发送回客户端。
流式传输视频
在我们的app.js
文件中,我们创建了一个/video
路由,为客户端提供视频。 我们希望此端点发送较小的视频块,而不是根据请求提供整个视频文件。
我们希望能够动态地提供allVideos
数组中的三个视频之一,并以块的形式流式传输视频,因此:
从app.js
中删除/video
路由。
我们需要三个视频,所以将教程源代码中的示例视频复制到server
项目的assets/
目录中。 确保视频的文件名对应于videos
数组中的id
:
回到我们的Video.js
文件,为流式视频创建路由。
router.get('/video/:id', (req, res) => { const videoPath = `assets/${req.params.id}.mp4`; const videoStat = fs.statSync(videoPath); const fileSize = videoStat.size; const videoRange = req.headers.range; if (videoRange) { const parts = videoRange.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize-1; const chunksize = (end-start) + 1; const file = fs.createReadStream(videoPath, {start, end}); const head = { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': 'video/mp4', }; res.writeHead(206, head); file.pipe(res); } else { const head = { 'Content-Length': fileSize, 'Content-Type': 'video/mp4', }; res.writeHead(200, head); fs.createReadStream(videoPath).pipe(res); } });
如果我们在浏览器中导航到 https://localhost:5000/videos/video/outside-the-wire,我们可以看到视频流。
流媒体视频路由的工作原理
在我们的流视频路由中编写了相当多的代码,所以让我们逐行查看。
const videoPath = `assets/${req.params.id}.mp4`; const videoStat = fs.statSync(videoPath); const fileSize = videoStat.size; const videoRange = req.headers.range;
首先,从我们的请求中,我们使用req.params.id
从路由中获取id
,并使用它来生成视频的videoPath
。 然后我们使用我们导入的文件系统fileSize
读取文件fs
。 对于视频,用户的浏览器将在请求中发送一个range
参数。 这让服务器知道将哪个视频块发送回客户端。
一些浏览器在初始请求中发送一个范围,但其他浏览器不发送。 对于那些没有,或者由于任何其他原因浏览器没有发送范围,我们在else
块中处理。 此代码获取文件大小并发送视频的前几块:
else { const head = { 'Content-Length': fileSize, 'Content-Type': 'video/mp4', }; res.writeHead(200, head); fs.createReadStream(path).pipe(res); }
我们将处理后续请求,包括if
块中的范围。
if (videoRange) { const parts = videoRange.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize-1; const chunksize = (end-start) + 1; const file = fs.createReadStream(videoPath, {start, end}); const head = { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': 'video/mp4', }; res.writeHead(206, head); file.pipe(res); }
上面的代码使用范围的start
值和end
值创建了一个读取流。 将响应标头的Content-Length
设置为根据start
值和end
值计算的块大小。 我们还使用 HTTP 代码 206,表示响应包含部分内容。 这意味着浏览器将继续发出请求,直到它获取所有视频块。
不稳定的连接会发生什么
如果用户的连接速度较慢,网络流将通过请求 I/O 源暂停来向它发出信号,直到客户端准备好接收更多数据。 这称为背压。 我们可以将这个例子更进一步,看看扩展流是多么容易。 我们也可以简单地添加压缩!
const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize-1; const chunksize = (end-start) + 1; const file = fs.createReadStream(videoPath, {start, end});
我们可以看到上面创建了一个ReadStream
并逐块提供视频块。
const head = { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': 'video/mp4', }; res.writeHead(206, head); file.pipe(res);
请求头包含Content-Range
,它是开始和结束更改以获取下一个视频块以流式传输到前端, content-length
是发送的视频块。 我们还指定了我们正在流式传输的内容类型,即mp4
。 206 的写入头设置为仅响应新创建的流。
为我们的视频创建字幕文件
这就是.vtt
字幕文件的样子。
WEBVTT 00:00:00.200 --> 00:00:01.000 Creating a tutorial can be very 00:00:01.500 --> 00:00:04.300 fun to do.
字幕文件包含视频中所说内容的文本。 它还包含何时显示每行文本的时间码。 我们希望我们的视频有字幕,并且我们不会为本教程创建自己的字幕文件,因此您可以前往 repo 中assets
目录中的字幕文件夹并下载字幕。
让我们创建一个处理字幕请求的新路由:
router.get('/video/:id/caption', (req, res) => res.sendFile(`assets/captions/${req.params.id}.vtt`, { root: __dirname }));
构建我们的前端
要开始我们系统的可视化部分,我们必须构建我们的前端脚手架。
注意:您需要 vue-cli 来创建我们的应用程序。 如果您的计算机上没有安装它,您可以运行npm install -g @vue/cli
来安装它。
安装
在我们项目的根目录,让我们创建我们的前端文件夹:
mkdir frontend cd frontend
在其中,我们初始化我们的package.json
文件,复制并粘贴以下内容:
{ "name": "my-app", "scripts": { "dev": "nuxt", "build": "nuxt build", "generate": "nuxt generate", "start": "nuxt start" } }
然后安装nuxt
:
npm add nuxt
并执行以下命令运行 Nuxt.js 应用程序:
npm run dev
我们的 Nuxt 文件结构
现在我们已经安装了 Nuxt,我们可以开始布置我们的前端了。
首先,我们需要在应用的根目录下创建一个layouts
文件夹。 这个文件夹定义了应用程序的布局,无论我们导航到哪个页面。 我们的导航栏和页脚之类的东西可以在这里找到。 在前端文件夹中,我们在启动前端应用程序时为默认布局创建default.vue
。
mkdir layouts cd layouts touch default.vue
然后是一个components
文件夹来创建我们所有的组件。 我们只需要两个组件, NavBar
和video
组件。 所以在前端的根文件夹中,我们:
mkdir components cd components touch NavBar.vue touch Video.vue
最后,一个 pages 文件夹可以创建我们所有的页面,比如home
和about
。 我们在这个应用程序中需要两个页面,一个是显示我们所有视频和视频信息的home
,另一个是一个动态播放器页面,该页面路由到我们点击的视频。
mkdir pages cd pages touch index.vue mkdir player cd player touch _name.vue
我们的前端目录现在看起来像这样:
|-frontend |-components |-NavBar.vue |-Video.vue |-layouts |-default.vue |-pages |-index.vue |-player |-_name.vue |-package.json |-yarn.lock
导航栏组件
我们的NavBar.vue
看起来像这样:
<template> <div class="navbar"> <h1>Streaming App</h1> </div> </template> <style scoped> .navbar { display: flex; background-color: #161616; justify-content: center; align-items: center; } h1{ color:#a33327; } </style>
NavBar
有一个显示Streaming App的h1
标签,并带有一些小样式。
让我们将NavBar
导入到我们的default.vue
布局中。
// default.vue <template> <div> <NavBar /> <nuxt /> </div> </template> <script> import NavBar from "@/components/NavBar.vue" export default { components: { NavBar, } } </script>
default.vue
布局现在包含我们的NavBar
组件和<nuxt />
标记,它表示我们创建的任何页面将显示在哪里。
在我们的index.vue
(这是我们的主页)中,让我们向https://localhost:5000/videos
发出请求以从我们的服务器获取所有视频。 将数据作为道具传递给我们稍后将创建的video.vue
组件。 但是现在,我们已经导入了它。
<template> <div> <Video :videoList="videos"/> </div> </template> <script> import Video from "@/components/Video.vue" export default { components: { Video }, head: { title: "Home" }, data() { return { videos: [] } }, async fetch() { this.videos = await fetch( 'https://localhost:5000/videos' ).then(res => res.json()) } } </script>
视频分量
下面,我们首先声明我们的 prop。 由于视频数据现在在组件中可用,使用 Vue 的v-for
我们迭代接收到的所有数据,并为每一个数据显示信息。 我们可以使用v-for
指令循环遍历数据并将其显示为列表。 还添加了一些基本样式。
<template> <div> <div class="container"> <div v-for="(video, id) in videoList" :key="id" class="vid-con" > <NuxtLink :to="`/player/${video.id}`"> <div : class="vid" ></div> <div class="movie-info"> <div class="details"> <h2>{{video.name}}</h2> <p>{{video.duration}}</p> </div> </div> </NuxtLink> </div> </div> </div> </template> <script> export default { props:['videoList'], } </script> <style scoped> .container { display: flex; justify-content: center; align-items: center; margin-top: 2rem; } .vid-con { display: flex; flex-direction: column; flex-shrink: 0; justify-content: center; width: 50%; max-width: 16rem; margin: auto 2em; } .vid { height: 15rem; width: 100%; background-position: center; background-size: cover; } .movie-info { background: black; color: white; width: 100%; } .details { padding: 16px 20px; } </style>
我们还注意到NuxtLink
有一个动态路由,即路由到/player/video.id
。
我们想要的功能是当用户点击任何视频时,它就会开始流式传输。 为了实现这一点,我们利用了_name.vue
路由的动态特性。
在其中,我们创建了一个视频播放器并将源设置为我们的端点以流式传输视频,但是我们在this.$route.params.name
的帮助下动态地将要播放的视频附加到我们的端点,它捕获了链接接收到的参数.
<template> <div class="player"> <video controls muted autoPlay> <source :src="`https://localhost:5000/videos/video/${vidName}`" type="video/mp4"> </video> </div> </template> <script> export default { data() { return { vidName: '' } }, mounted(){ this.vidName = this.$route.params.name } } </script> <style scoped> .player { display: flex; justify-content: center; align-items: center; margin-top: 2em; } </style>
当我们点击我们得到的任何视频时:
添加我们的字幕文件
要添加我们的曲目文件,我们确保标题文件夹中的所有.vtt
文件与我们的id
具有相同的名称。 使用轨道更新我们的视频元素,请求字幕。
<template> <div class="player"> <video controls muted autoPlay crossOrigin="anonymous"> <source :src="`https://localhost:5000/videos/video/${vidName}`" type="video/mp4"> <track label="English" kind="captions" srcLang="en" :src="`https://localhost:5000/videos/video/${vidName}/caption`" default> </video> </div> </template>
我们在视频元素中添加了crossOrigin="anonymous"
; 否则,字幕请求将失败。 现在刷新,您会看到字幕已成功添加。
构建弹性视频流时要记住什么。
在构建 Twitch、Hulu 或 Netflix 等流媒体应用程序时,需要考虑许多事项:
- 视频数据处理管道
这可能是一个技术挑战,因为需要高性能服务器来为用户提供数百万个视频。 应不惜一切代价避免高延迟或停机时间。 - 缓存
在构建此类应用程序示例 Cassandra、Amazon S3、AWS SimpleDB 时,应使用缓存机制。 - 用户地理
考虑到您的用户的地理位置,应该考虑分发。
结论
在本教程中,我们了解了如何在 Node.js 中创建一个服务器来流式传输视频、为这些视频生成字幕并提供视频的元数据。 我们还看到了如何在前端使用 Nuxt.js 来使用端点和服务器生成的数据。
与其他框架不同,使用 Nuxt.js 和 Express.js 构建应用程序非常简单快捷。 Nuxt.js 最酷的部分是它管理您的路线并让您更好地构建应用程序的方式。
- 您可以在此处获取有关 Nuxt.js 的更多信息。
- 你可以在 Github 上获取源代码。
资源
- “为 HTML5 视频添加字幕和字幕”,MDN Web Docs
- “理解字幕和字幕”,Screenfont.ca