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. nvm.
- 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
파일을 자산 폴더에 복사하고 이름을 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!') });
nodemon을 사용하여 서버를 시작하는 스크립트가 package.json
파일에 추가됩니다.
"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 경로에 Video.js
파일을 추가합니다. 이 파일에서는 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
를 생성합니다. 그런 다음 가져온 파일 시스템 fs
를 사용하여 fileSize
를 읽습니다. 비디오의 경우 사용자의 브라우저는 요청에 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.
캡션 파일에는 비디오에서 말하는 내용에 대한 텍스트가 포함되어 있습니다. 또한 각 텍스트 행이 표시되어야 하는 시간 코드가 포함되어 있습니다. 우리는 비디오에 캡션이 있기를 원하며 이 튜토리얼에서 자체 캡션 파일을 만들지 않을 것이므로 리포지토리의 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
마지막으로 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 구성 요소
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
에는 약간의 스타일 지정과 함께 스트리밍 앱 을 표시하는 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>
video 요소에 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