使用 Nuxt.js、Node 和 Express 構建視頻流應用

已發表: 2022-03-10
快速總結↬在本文中,我們將使用 Nuxt.js 和 Node.js 構建一個視頻流應用程序。 具體來說,我們將構建一個服務器端 Node.js 應用程序,該應用程序將處理獲取和流式傳輸視頻、為您的視頻生成縮略圖以及提供字幕和字幕。

視頻與流一起使用。 這意味著不是一次發送整個視頻,而是將視頻作為一組組成完整視頻的較小塊發送。 這解釋了為什麼在慢速寬帶上觀看視頻時視頻會緩衝,因為它只播放它收到的塊並嘗試加載更多。

本文適用於願意通過構建實際項目來學習新技術的開發人員:一個以 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文件夾來創建我們所有的組件。 我們只需要兩個組件, NavBarvideo組件。 所以在前端的根文件夾中,我們:

 mkdir components cd components touch NavBar.vue touch Video.vue

最後,一個 pages 文件夾可以創建我們所有的頁面,比如homeabout 。 我們在這個應用程序中需要兩個頁面,一個是顯示我們所有視頻和視頻信息的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 Apph1標籤,並帶有一些小樣式。

讓我們將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>

當我們點擊我們得到的任何視頻時:

Nuxt 視頻流媒體應用最終結果
當用戶單擊縮略圖時,視頻流開始。 (大預覽)

添加我們的字幕文件

要添加我們的曲目文件,我們確保標題文件夾中的所有.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