El otro día me vi en la necesidad de montar un pequeño sitio donde se pudiera visualizar feeds de vídeo provenientes desde cámaras IP a través del internet en tiempo real. Suena como un escenario completamente realizable en este modernísimo año 2019, ¿correcto?

Para mi sorpresa no es una tarea que se pueda considerar plug & play y requirió mucho más trabajo del que me hubiera gustado hacer. Sin embargo todos los días se aprende algo nuevo y en el afán de evitar un par de dolores de cabeza a quien esté por emprender un proyecto similar he decidido compartir los descubrimientos y el código necesario para llegar al resultado deseado.

El protocolo de vídeo

La gran mayoría de cámaras que se venden en el mercado como accesibles desde internet usualmente ya cuentan con una interfaz y aplicativos para acceder en tiempo real al feed de vídeo. Sin embargo en este caso muy particular me encontré con que el cliente había adquirido e instalado un conjunto de cámaras que si bien estaban expuestas al mundo mediante internet y una IP fija, el protocolo de transmisión que utilizan es el RTSP. Debo reconocer que el vídeo y sus protocolos no son lo mío, ingenuamente asumí que para mostrar el feed en vivo bastaba con apuntar un reproductor de vídeo embebido en una página web hacia dicho feed y que todo nuestro conocimiento colectivo y librerías existentes harían el trabajo pesado por mi. No fue así, el protocolo RTSP no es reconocido de forma nativa por el estándar HTML5 y por lo tanto fue en ese momento que empecé a comprender que tenía una tarea nada sencilla frente a mi.

Incluso antes de saber que íbamos a necesitar convertir el feed de vídeo en tiempo real ya sabíamos que probablemente íbamos a necesitar algún tipo de servidor que nos serviría para conectar nuestro aplicativo web con la información que necesitamos desplegar en ella. Ahora que sabemos que necesitamos un paso previo de conversión del feed de vídeo toma incluso más relevancia la necesidad de utilizar un aplicativo del lado del servidor que haga todas estas tareas en segundo plano por nosotros.

Para poder mostrar un feed en vivo proveniente de una cámara que transmite mediante el protocolo RTSP es necesario primero un paso de conversión a un formato de vídeo más universal. El principal inconveniente es hacerlo en tiempo real y mostrar un feed de vídeo con milisegundos de diferencia con respecto al feed original. La forma más sencilla de hacerlo resultó ser mediante una utilidad bastante conocida llamada ffmpeg y que funciona prácticamente en cualquier plataforma. ffmpeg puede tomar el feed de vídeo proviniente en protocolo RTSP y convertirlo al vuelo en un stream de vídeo en formato mpeg. Esto resuelve la primera parte del rompecabezas.

El servidor

Como es usual, utilizaremos un aplicativo basado en Node.js  para que se encargue no sólo de realizar la conversión del vídeo, también que nos permita definir algunos endpoints desde donde hacer el streaming hacia nuestro aplicativo web que desplegará el feed en vivo de nuestro vídeo proveniente de nuestras cámaras IP.

Utilizaremos únicamente 2 librerías adicionales al core de Node.js para este fin.

yarn add express
yarn add ws

Adicionalmente necesitamos tener instalado ffmpeg en el equipo o servidor que estará ejecutando nuestro aplicativo. En un entorno Linux es tan sencillo como instalarlo con un apt-get install para distribuciones basadas en Debian o bien mediante yum install para distribuciones basadas en Red Hat. No entraré en detalles de cómo agregar los repositorios necesarios. Por otro lado, en entornos Windows es necesario tener los binarios de ffmpeg disponibles y agregar dicha ubicación a la variable de entorno PATH, de tal forma que el comando ffmpeg pueda ser invocado desde cualquier ubicación en el sistema operativo y resuelva siempre hacia la ubicación de los binarios disponibles para dicho sistema. Nuevamente, no entraré en mayores detalles acerca de como instalar ffmpeg en un sistema específico, considero que con esta descripción y un poco de investigación es suficiente para llegar al mismo resultado.

Una vez completas nuestras dependencias y librerías, nuestro aplicativo del lado del servidor se compone de dos piezas: un endpoint que recibe el feed de vídeo proveniente desde ffmpeg y un websocket que envía en tiempo real el feed a todos los clientes conectados mediante la aplicación web.

Para configurar el endpoint que recibirá el feed de vídeo proveniente de la conversión que realizará ffmpeg lo definimos de la siguiente manera:

const express = require( 'express' );
const server = express();
let router = express.Router();

router.post('/feeds/:stream', (req, res) => {
    let stream = streams.find(stream => { return stream.id === req.params.stream;});
	
    req.on('data', (data) => {
	    if (stream) {
            socketServer.broadcast(data, stream.id);
		}
	});
	
	req.on('end', () => {
        if (stream) {
            stream.online = false;
        }

	    res.end();
	});
});

server.use(router);
server.listen(4000);

Este endpoint recibe como parámetro un identificador del stream al que nos queremos conectar (por ello el find para hacer match entre el parámetro suministrado y un listado de streams que en un momento veremos), luego espera a que la petición dispare el evento data, que es el momento en el que realmente empieza a fluir el feed de vídeo. Finalmente al recibir el final de una transmisión desconecta el endpoint y finaliza la petición.

De este bloque sin embargo lo más importante es lo que sucede dentro del evento data. Al empezar a fluir el feed de vídeo y haberse encontrado un stream válido de nuestro listado, se invoca una función llamada broadcast de un objeto socketServer. Este es el que se encarga de transmitir el vídeo a todos los clientes conectados mediante nuestra aplicación web. Veamos primero cómo construimos este objeto y la función para transmitir:

let socketServer = new WebSocket.Server({ port: 4001, perMessageDeflate: false });
socketServer.connectionCount = 0;

socketServer.on('connection', function(socket, upgradeReq) {
    if ( !upgradeReq.url ) {
        socket.close();
    } else {
        let room = upgradeReq.url.toString().substring(1);
        let stream = streams.find(stream => {
            return stream.id === room;
        });

        if ( stream ) {
            socket.room = room;
            stream.connections++;

            if ( !stream.online ) {
                const { spawn } = require( 'child_process' );
                const child = spawn('ffmpeg', ['-hide_banner', '-rtsp_transport', 'tcp', '-i', stream.feed, '-f', 'mpegts', '-codec:v', 'mpeg1video', '-bf', '0', '-codec:a', 'mp2', '-r', '30', `http://127.0.0.1:4000/${stream.id}`] );
                stream.process = child;
                stream.online = true;
            }
			
			console.log ('\x1b[1m\x1b[32m', `> New connection for stream ${stream.id} > ${(upgradeReq || socket.upgradeReq).socket.remoteAddress}`, '\x1b[0m');
			console.log ('\x1b[1m\x1b[37m', `${stream.connections} ${(stream.connections > 1 ? 'clients' : 'client')} watching the live feed`, '\x1b[0m');

            socket.on('close', function(code, message){
                stream.connections--;
                console.log ('\x1b[1m\x1b[31m', `Client disconnected from stream ${stream.id}`, '\x1b[0m');
                console.log ('\x1b[1m\x1b[37m', `${stream.connections} ${(stream.connections === 1 ? 'client' : 'clients')} watching the live feed`, '\x1b[0m');
                
                if ( stream.connections === 0 ) {
                    let process = stream.process;
                    process.stdin.pause();
                    process.kill();
                   
                    setTimeout(() => { 
                        stream.process = undefined;
                        stream.online = false;
                    }, 1000);
                }
            });
        }
    }
});

Acá es donde realmente sucede todo lo interesante. Cuando el servidor de websockets recibe una nueva conexión se busca en la URL de la petición el id del feed que se está intentando visualizar e internamente se asigna la propiedad room a dicho socket, para saber identificar posteriormente que conexiones están viendo un feed específico. Esto lo hice debido a que estoy acostumbrado al concepto de salas (rooms) de socket.io, el cual es un concepto desconocido cuando se está trabajando con un websocket puro como es nuestro caso específico en este momento.

Luego de identificar la sala (feed específico) y setear el socket para que escuche únicamente feeds de vídeo específicos sin riesgo de que se les transmita otros feeds de vídeo o que se crucen si hay más de un cliente conectado, la parte medular de nuestro aplicativo del lado del servidor es levantar un proceso en segundo plano con ffmpeg. Una de las facilidades de Node.js es poder levantar un proceso secundario (otro aplicativo usualmente) mediante la utilidad child_process y poder cerrarlo cuando ya no es necesario. Veamos a detalle lo que está haciendo este bloque:

const { spawn } = require( 'child_process' );
const child = spawn(
    'ffmpeg',
    ['-hide_banner',
    '-rtsp_transport',
    'tcp',
    '-i',
    stream.feed,
    '-f',
    'mpegts',
    '-codec:v',
    'mpeg1video',
    '-bf',
    '0',
    '-codec:a',
    'mp2',
    '-r',
    '30',
    `http://127.0.0.1:4000/${stream.id}`]);

stream.process = child;
stream.online = true;

Me he tomado la libertad de expandir los parámetros y formatearlo de forma que sea más evidente lo que está sucediendo. La utilería child_process cuenta con una función llamada spawn, que permite crear N cantidad de procesos secundarios a partir de cualquier aplicativo. En este caso estamos creando un proceso a partir de ffmpeg por cada stream de vídeo que es solicitado a nuestro aplicativo. El segundo parámetro de la función spawn es un array con todos los parámetros que deseamos enviarle al aplicativo que estamos levantando. La documentación de ffmpeg es realmente muy extensa y para quien quiera ahondar en qué significa cada uno de dichos parámetros tendrá horas de diversión explorando el sitio web de dicha utilería. Acá los puntos importantes son el parámetro -i seguido de la URL de nuestra cámara (lo veremos en un momento) y el último parámetro, la URL http://127.0.0.1:4000/stream, que no es otra cosa más que el endpoint que definimos anteriormente.

Básicamente al recibir una conexión mediante websocket, nuestro aplicativo levanta un proceso ffmpeg en segundo plano y se conecta al endpoint que nosotros mismos hemos definido anteriormente y le pasa el stream ya convertido de vídeo listo para ser transmitido a cualquier cliente conectado a nuestra aplicación web. Alguien se preguntará, ¿qué es lo que realmente está fluyendo entre ffmpeg y el endpoint que recibe este output? No es otra cosa más que un buffer stream de bytes del feed de vídeo ya convertido por ffmpeg. Es equivalente a abrir un archivo físico y leer el buffer stream resultante, con la diferencia que acá el stream no tiene un inicio y un fin definido como si fuera un archivo con un tamaño previamente establecido.

Antes de pasar al aplicativo web, en varios lugares hemos visto que hacemos búsquedas hacia un objeto streams y utilizamos algunas de sus propiedades. No es más que un array con un par de propiedades asociadas a cada elemento:

let streams = [{
    connections: 0,
    feed: 'rtsp://usuario:[email protected]:50000/stream0',
    id: 'd36f08c5',
    online: false,
    process: undefined 
}, {
    connections: 0,
    feed: 'rtsp://usuario:[email protected]:50000/stream1',
    id: 'ef0b0e85',
    online: false,
    process: undefined
}];

La propiedad feed es la URL que apunta hacia nuestra cámara IP en el campo. El ID es el único valor que debe conocer nuestro aplicativo web para solicitar el feed de vídeo correspondiente.

El aplicativo web

Para mostrar nuestro feed de vídeo estoy utilizando un aplicativo construido en React. Debido a que el cliente requería poder visualizar la ubicación de las cámaras sobre un mapa, he utilizado un componente llamado google-map-react. Para desplegar el feed de vídeo he utilizado el componente jsmpeg-player.

Las cámaras como marcadores en el mapa

Cargar las cámaras al mapa como marcadores queda como tarea, acá les compartiré cómo he tomado el componente jsmpeg-player y lo he empaquetado en un componente de React para mostrarlo al seleccionar un feed. Adicionalmente estoy utilizando la increíble colección de controles Semantic UI React que me permite tener una amplia selección de controles listos para utilizarse y que interactuan sin ninguna complicación con el ciclo de vida de componentes y aplicativos de React.

El cliente en este caso ha optado por cámaras que tienen dos ángulos distintos. Básicamente son dos cámaras con un feed de vídeo independiente y que puede ser accesado de forma individual. He armado el mapa de tal forma que al pasar el cursor sobre un ícono de una cámara se muestra el cuadro que se ve en la imágen, permitiendo al usuario seleccionar entre cual de los dos feeds de vídeo desea visualizar. Cada uno de estos feeds está definido en nuestro aplicativo Node.js, y al seleccionar alguno de ellos abre una conexión hacia nuestro websocket solicitando el feed de vídeo.

Antes de ver el resultado final, el componente de vídeo es un componente estándar de React con la librería jsmpeg-player dentro del mismo.

import React, { Component } from 'react';
import { Dimmer, Modal, Loader, Segment } from 'semantic-ui-react';
import JSMpeg from 'jsmpeg-player';

class Player extends Component {
    constructor( props ) {
        super(props);

        this.state = {
            feedReady: false,
            player: null
        }
	
        this.cleanup = this.cleanup.bind(this);
    }

    componentWillReceiveProps( nextProps ) {
        if ( nextProps.showLiveFeed ) {
            setTimeout(() => {
                let player = new JSMpeg.VideoElement(
                    this.refs.video,
                    this.props.feedUrl,
                    { hooks: { load: () => { this.setState({ feedReady: true }); } } }
				);

                this.setState({ player: player });
            }, 500);
        }
	}

    cleanup = () => {
        let player = this.state.player;
        player.destroy();
        this.setState({ feedReady: false, player: null });
        this.props.closeLiveFeed();
    }

    render() {
        return (
            <Modal
                closeIcon={true}
                onClose={() => { this.cleanup() }}
                open={this.props.showLiveFeed}>
                <Modal.Header>Live feed<Modal.Header>
                <Modal.Content>
                { this.props.showLiveFeed ? 
                    <Segment basic>
                        <Dimmer active={!this.state.feedReady}>
                            <Loader>Acquiring live feed</Loader>
                        </Dimmer>
                        <div ref="video" style={{ 'height': '450px', 'width': '100%' }}></div>
                    </Segment> : null }
                <Modal.Content>
            <Modal>
        )
    }
}

export default Player;

Este componente es de tipo ventana modal, que al solicitarse visualizar un feed de vídeo recibe el prop llamado showLiveFeed. Inicialmente tuve muchos problemas con jsmpeg-player si se dejaba sin inicializar esperando a que la ventana se mostrara, por lo que encontré que la mejor forma de prevenir esto era mantener el control sin renderizarse hasta que dicho prop fuera igual a true. Adicionalmente al recibirse showLiveFeed toma un tiempo entre que se inicializa el reproductor y está listo para ser renderizado, por lo que nuevamente tuve que darle 500 milisegundos de espera mediante un setTimeout dentro de componentWillReceiveProps previo a ser renderizado.

Mientras el feed de vídeo se encuentra disponible, un Loader de la colección de controles de Semantic UI se desplegará para indicarle al usuario que el vídeo se está procesando.

Finalmente, si todo ha salido bien el vídeo empezará a fluir y nuestros usuarios podrán visualizar en tiempo real lo que está sucediendo en el campo desde nuestro aplicativo web.

Y mientras todo esto sucede, nuestro aplicativo Node.js nos irá indicando conforme los eventos van sucediendo:

He dejado intencionalmente muchos detalles fuera, algunos porque son muy específicos al aplicativo desarrollado para el cliente, pero también hay otros detalles que considero deben ser investigados y resueltos por el desarrollador que tiene ante si un problema similar a este.