使用 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