Creación de una aplicación de transmisión de video con Nuxt.js, Node y Express

Publicado: 2022-03-10
Resumen rápido ↬ En este artículo, crearemos una aplicación de transmisión de video usando Nuxt.js y Node.js. Específicamente, crearemos una aplicación Node.js del lado del servidor que manejará la obtención y transmisión de videos, la generación de miniaturas para sus videos y la entrega de leyendas y subtítulos.

Los videos funcionan con secuencias. Esto significa que en lugar de enviar el video completo de una vez, se envía un video como un conjunto de fragmentos más pequeños que conforman el video completo. Esto explica por qué los videos se almacenan en el búfer cuando se ve un video en banda ancha lenta porque solo reproduce los fragmentos que ha recibido e intenta cargar más.

Este artículo es para desarrolladores que están dispuestos a aprender una nueva tecnología mediante la creación de un proyecto real: una aplicación de transmisión de video con Node.js como backend y Nuxt.js como cliente.

  • Node.js es un tiempo de ejecución que se utiliza para crear aplicaciones rápidas y escalables. Lo usaremos para gestionar la obtención y transmisión de videos, la generación de miniaturas para videos y la entrega de leyendas y subtítulos para videos.
  • Nuxt.js es un marco Vue.js que nos ayuda a crear fácilmente aplicaciones Vue.js renderizadas por el servidor. Consumiremos nuestra API para los videos y esta aplicación tendrá dos vistas: una lista de videos disponibles y una vista de reproductor para cada video.

requisitos previos

  • Comprensión de HTML, CSS, JavaScript, Node/Express y Vue.
  • Un editor de texto (por ejemplo, VS Code).
  • Un navegador web (por ejemplo, Chrome, Firefox).
  • FFmpeg instalado en su estación de trabajo.
  • Nodo.js. nvm.
  • Puede obtener el código fuente en GitHub.

Configurando nuestra aplicación

En esta aplicación construiremos las rutas para realizar solicitudes desde el frontend:

  • ruta de videos para obtener una lista de videos y sus datos.
  • una ruta para obtener solo un video de nuestra lista de videos.
  • ruta de streaming para transmitir los videos.
  • ruta de captions para agregar subtítulos a los videos que estamos transmitiendo.

Después de que se hayan creado nuestras rutas, montaremos nuestra interfaz de Nuxt , donde crearemos la página de Home y el player dinámico. Luego, solicitamos nuestra ruta de videos para llenar la página de inicio con los datos del video, otra solicitud para transmitir los videos en nuestra página de player y, finalmente, una solicitud para servir los archivos de subtítulos que usarán los videos.

Para configurar nuestra aplicación, creamos nuestro directorio de proyectos,

 mkdir streaming-app
¡Más después del salto! Continúe leyendo a continuación ↓

Configurando Nuestro Servidor

En nuestro directorio streaming-app , creamos una carpeta llamada backend .

 cd streaming-app mkdir backend

En nuestra carpeta de back-end, inicializamos un archivo package.json para almacenar información sobre nuestro proyecto de servidor.

 cd backend npm init -y

Necesitamos instalar los siguientes paquetes para construir nuestra aplicación.

  • nodemon reinicia automáticamente nuestro servidor cuando hacemos cambios.
  • express nos brinda una buena interfaz para manejar rutas.
  • cors nos permitirá realizar solicitudes de origen cruzado ya que nuestro cliente y servidor se ejecutarán en diferentes puertos.

En nuestro directorio de back-end, creamos una carpeta de assets para guardar nuestros videos para la transmisión.

 mkdir assets

Copie un archivo .mp4 en la carpeta de activos y asígnele el nombre video1 . Puede usar videos de muestra cortos .mp4 que se pueden encontrar en Github Repo.

Cree un archivo app.js y agregue los paquetes necesarios para nuestra aplicación.

 const express = require('express'); const fs = require('fs'); const cors = require('cors'); const path = require('path'); const app = express(); app.use(cors())

El módulo fs se usa para leer y escribir en archivos fácilmente en nuestro servidor, mientras que el módulo de path proporciona una forma de trabajar con directorios y rutas de archivos.

Ahora creamos una ruta ./video . Cuando se solicite, enviará un archivo de video al cliente.

 // add after 'const app = express();' app.get('/video', (req, res) => { res.sendFile('assets/video1.mp4', { root: __dirname }); });

Esta ruta entrega el archivo de video video1.mp4 cuando se solicita. Luego escuchamos nuestro servidor en el puerto 3000 .

 // add to end of app.js file app.listen(5000, () => { console.log('Listening on port 5000!') });

Se agrega un script en el archivo package.json para iniciar nuestro servidor usando nodemon.

 "scripts": { "start": "nodemon app.js" },

Luego en tu terminal ejecuta:

 npm run start

Si ve el mensaje Listening on port 3000! en la terminal, entonces el servidor está funcionando correctamente. Navegue a https://localhost:5000/video en su navegador y debería ver la reproducción del video.

Solicitudes para ser manejadas por la interfaz

A continuación se muestran las solicitudes que haremos al backend desde nuestro frontend que necesitamos que maneje el servidor.

  • /videos
    Devuelve una matriz de datos de maquetas de video que se usarán para completar la lista de videos en la página de Home en nuestra interfaz.
  • /video/:id/data
    Devuelve metadatos para un solo video. Utilizado por la página del Player en nuestra interfaz.
  • /video/:id
    Transmite un video con una identificación dada. Utilizado por la página del Player .

Vamos a crear las rutas.

Devolver datos de maqueta para la lista de videos

Para esta aplicación de demostración, crearemos una matriz de objetos que contendrá los metadatos y los enviará a la interfaz cuando se solicite. En una aplicación real, probablemente estaría leyendo los datos de una base de datos, que luego se usaría para generar una matriz como esta. En aras de la simplicidad, no haremos eso en este tutorial.

En nuestra carpeta backend, cree un archivo mockdata.js y complételo con metadatos para nuestra lista de videos.

 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

Podemos ver desde arriba, cada objeto contiene información sobre el video. Observe el atributo de poster que contiene el enlace a una imagen de póster del video.

Vamos a crear una ruta de videos ya que todas nuestras solicitudes que debe realizar la interfaz están antepuestas con /videos .

Para hacer esto, creemos una carpeta de routes y agreguemos un archivo Video.js para nuestra ruta /videos . En este archivo, necesitaremos express y usaremos el enrutador express para crear nuestra ruta.

 const express = require('express') const router = express.Router()

Cuando vamos a la ruta /videos , queremos obtener nuestra lista de videos, así que solicitemos el archivo mockData.js en nuestro archivo Video.js y hagamos nuestra solicitud.

 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;

La ruta /videos ahora está declarada, guarde el archivo y debería reiniciar automáticamente el servidor. Una vez que se haya iniciado, vaya a https://localhost:3000/videos y nuestra matriz se devolverá en formato JSON.

Devolver datos para un solo video

Queremos poder realizar una solicitud de un video en particular en nuestra lista de videos. Podemos obtener datos de video en particular en nuestra matriz usando la id que le dimos. Hagamos una solicitud, aún en nuestro archivo 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]) })

El código anterior obtiene la id de los parámetros de la ruta y la convierte en un número entero. Luego, enviamos el objeto que coincide con la id de la matriz de videos al cliente.

Transmisión de los videos

En nuestro archivo app.js , creamos una ruta /video que entrega un video al cliente. Queremos que este punto final envíe fragmentos más pequeños del video, en lugar de entregar un archivo de video completo a pedido.

Queremos poder servir dinámicamente uno de los tres videos que se encuentran en la matriz allVideos y transmitir los videos en fragmentos, por lo que:

Elimine la ruta /video de app.js .

Necesitamos tres videos, así que copie los videos de ejemplo del código fuente del tutorial en el directorio assets/ de su proyecto de server . Asegúrese de que los nombres de archivo de los videos correspondan a la id en la matriz de videos :

De vuelta en nuestro archivo Video.js , cree la ruta para la transmisión de videos.

 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); } });

Si navegamos a https://localhost:5000/videos/video/outside-the-wire en nuestro navegador, podemos ver la transmisión de video.

Cómo funciona la ruta de transmisión de video

Hay bastante código escrito en nuestra ruta de transmisión de video, así que veámoslo línea por línea.

 const videoPath = `assets/${req.params.id}.mp4`; const videoStat = fs.statSync(videoPath); const fileSize = videoStat.size; const videoRange = req.headers.range;

Primero, de nuestra solicitud, obtenemos la id de la ruta usando req.params.id y la usamos para generar el videoPath al video. Luego leemos el fileSize del archivo usando el sistema de archivos que fs . Para videos, el navegador de un usuario enviará un parámetro de range en la solicitud. Esto le permite al servidor saber qué parte del video debe enviar al cliente.

Algunos navegadores envían un rango en la solicitud inicial, pero otros no. Para aquellos que no lo hacen, o si por alguna otra razón el navegador no envía un rango, lo manejamos en el bloque else . Este código obtiene el tamaño del archivo y envía los primeros fragmentos del video:

 else { const head = { 'Content-Length': fileSize, 'Content-Type': 'video/mp4', }; res.writeHead(200, head); fs.createReadStream(path).pipe(res); }

Manejaremos las solicitudes posteriores, incluido el rango en un bloque 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); }

Este código anterior crea un flujo de lectura utilizando los valores start y end del rango. Establezca Content-Length de los encabezados de respuesta en el tamaño de fragmento que se calcula a partir de los valores start y end . También usamos el código HTTP 206, lo que significa que la respuesta contiene contenido parcial. Esto significa que el navegador seguirá realizando solicitudes hasta que haya obtenido todos los fragmentos del video.

Qué sucede en las conexiones inestables

Si el usuario tiene una conexión lenta, el flujo de red lo señalará solicitando que la fuente de E/S haga una pausa hasta que el cliente esté listo para recibir más datos. Esto se conoce como contrapresión . Podemos llevar este ejemplo un paso más allá y ver lo fácil que es extender la transmisión. ¡También podemos agregar compresión fácilmente!

 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});

Podemos ver arriba que se crea un ReadStream y sirve el video fragmento por fragmento.

 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);

El encabezado de la solicitud contiene el Content-Range , que es el cambio de inicio y final para obtener la siguiente porción de video para transmitir a la interfaz, la content-length es la porción de video enviada. También especificamos el tipo de contenido que estamos transmitiendo, que es mp4 . El cabezal de escritura de 206 está configurado para responder solo con flujos recién creados.

Creación de un archivo de subtítulos para nuestros videos

Así es como se ve un archivo de subtítulos .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.

Los archivos de subtítulos contienen texto para lo que se dice en un video. También contiene códigos de tiempo para mostrar cada línea de texto. Queremos que nuestros videos tengan subtítulos y no crearemos nuestro propio archivo de subtítulos para este tutorial, por lo que puede dirigirse a la carpeta de subtítulos en el directorio de assets en el repositorio y descargar los subtítulos.

Vamos a crear una nueva ruta que manejará la solicitud de subtítulos:

 router.get('/video/:id/caption', (req, res) => res.sendFile(`assets/captions/${req.params.id}.vtt`, { root: __dirname }));

Construyendo nuestra interfaz

Para comenzar con la parte visual de nuestro sistema, tendríamos que construir nuestro andamio frontend.

Nota : necesita vue-cli para crear nuestra aplicación. Si no lo tiene instalado en su computadora, puede ejecutar npm install -g @vue/cli para instalarlo.

Instalación

En la raíz de nuestro proyecto, creemos nuestra carpeta front-end:

 mkdir frontend cd frontend

y en él, inicializamos nuestro archivo package.json , copiamos y pegamos lo siguiente en él:

 { "name": "my-app", "scripts": { "dev": "nuxt", "build": "nuxt build", "generate": "nuxt generate", "start": "nuxt start" } }

luego instale nuxt :

 npm add nuxt

y ejecute el siguiente comando para ejecutar la aplicación Nuxt.js:

 npm run dev

Nuestra estructura de archivos Nuxt

Ahora que tenemos instalado Nuxt, podemos comenzar a diseñar nuestra interfaz.

Primero, necesitamos crear una carpeta de layouts en la raíz de nuestra aplicación. Esta carpeta define el diseño de la aplicación, sin importar la página a la que naveguemos. Cosas como nuestra barra de navegación y el pie de página se encuentran aquí. En la carpeta de interfaz, creamos default.vue para nuestro diseño predeterminado cuando iniciamos nuestra aplicación de interfaz.

 mkdir layouts cd layouts touch default.vue

Luego una carpeta de components para crear todos nuestros componentes. Necesitaremos solo dos componentes, NavBar y componente de video . Entonces, en nuestra carpeta raíz de frontend:

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

Finalmente, una carpeta de páginas donde se pueden crear todas nuestras páginas como home y about . Las dos páginas que necesitamos en esta aplicación son la página home que muestra todos nuestros videos y la información del video y una página dinámica del reproductor que dirige al video en el que hacemos clic.

 mkdir pages cd pages touch index.vue mkdir player cd player touch _name.vue

Nuestro directorio frontend ahora se ve así:

 |-frontend |-components |-NavBar.vue |-Video.vue |-layouts |-default.vue |-pages |-index.vue |-player |-_name.vue |-package.json |-yarn.lock

Componente de la barra de navegación

Nuestro NavBar.vue se ve así:

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

La NavBar de navegación tiene una etiqueta h1 que muestra Streaming App , con un poco de estilo.

NavBar la barra de navegación en nuestro diseño default.vue .

 // default.vue <template> <div> <NavBar /> <nuxt /> </div> </template> <script> import NavBar from "@/components/NavBar.vue" export default { components: { NavBar, } } </script>

El diseño NavBar default.vue la <nuxt /> después de esto indica dónde se mostrará cualquier página que creemos.

En nuestro index.vue (que es nuestra página de inicio), hagamos una solicitud a https://localhost:5000/videos para obtener todos los videos de nuestro servidor. Pasando los datos como accesorio a nuestro componente video.vue que crearemos más tarde. Pero por ahora, ya lo hemos importado.

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

Componente de vídeo

A continuación, primero declaramos nuestro prop. Dado que los datos de video ahora están disponibles en el componente, usando v-for de Vue iteramos en todos los datos recibidos y para cada uno, mostramos la información. Podemos usar la directiva v-for para recorrer los datos y mostrarlos como una lista. También se ha agregado un estilo básico.

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

También notamos que NuxtLink tiene una ruta dinámica, que se dirige a /player/video.id .

La funcionalidad que queremos es que cuando un usuario haga clic en cualquiera de los videos, comience a transmitir. Para lograr esto, hacemos uso de la naturaleza dinámica de la ruta _name.vue .

En él, creamos un reproductor de video y configuramos la fuente en nuestro punto final para transmitir el video, pero agregamos dinámicamente qué video reproducir a nuestro punto final con la ayuda de this.$route.params.name que captura qué parámetro recibió el enlace .

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

Cuando hacemos clic en cualquiera de los videos obtenemos:

Resultado final de la aplicación de transmisión de video Nuxt
La transmisión de video comienza cuando el usuario hace clic en la miniatura. (Vista previa grande)

Agregar nuestro archivo de subtítulos

Para agregar nuestro archivo de seguimiento, nos aseguramos de que todos los archivos .vtt en la carpeta de subtítulos tengan el mismo nombre que nuestra id . Actualice nuestro elemento de video con la pista, solicitando los subtítulos.

 <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" al elemento de video; de lo contrario, la solicitud de subtítulos fallará. Ahora actualice y verá que los subtítulos se han agregado correctamente.

Qué tener en cuenta al crear una transmisión de video resistente.

Al crear aplicaciones de transmisión como Twitch, Hulu o Netflix, hay una serie de cosas que se tienen en cuenta:

  • Tubería de procesamiento de datos de video
    Esto puede ser un desafío técnico, ya que se necesitan servidores de alto rendimiento para entregar millones de videos a los usuarios. Debe evitarse a toda costa la alta latencia o el tiempo de inactividad.
  • almacenamiento en caché
    Se deben utilizar mecanismos de almacenamiento en caché al crear este tipo de aplicación, por ejemplo Cassandra, Amazon S3, AWS SimpleDB.
  • Geografía de los usuarios
    Teniendo en cuenta la geografía de sus usuarios, se debe pensar en la distribución.

Conclusión

En este tutorial, hemos visto cómo crear un servidor en Node.js que transmite videos, genera subtítulos para esos videos y sirve metadatos de los videos. También vimos cómo usar Nuxt.js en la interfaz para consumir los puntos finales y los datos generados por el servidor.

A diferencia de otros marcos, crear una aplicación con Nuxt.js y Express.js es bastante fácil y rápido. Lo bueno de Nuxt.js es la forma en que administra sus rutas y le ayuda a estructurar mejor sus aplicaciones.

  • Puede obtener más información sobre Nuxt.js aquí.
  • Puede obtener el código fuente en Github.

Recursos

  • “Agregar leyendas y subtítulos a video HTML5”, MDN Web Docs
  • "Comprensión de títulos y subtítulos", Screenfont.ca