17 de abril de 2013

Usando Red5 para guardar un video de webcam a un servidor (AS3)

Hace unos días me encontré con algo que en mi mente era muy fácil de hacer, pero en la práctica tuve más problemas de los esperados; grabar un video con una webcam y que se guardara directamente en un servidor.

Primero intenté hacerlo con HTML5 y su capacidad de usar webcam, pero me encontré con varios problemas y con soluciones nada óptimas (como grabar frame por frame las imágenes y juntarlo todo después). Gracias a esto decidí que lo mejor sería usar Flash para manejar la webcam. Haciendo un poco de investigación encontré que se necesitaba de un servidor de multimedia y ahorita en el mercado hay 3 opciones de servidores de este tipo para Flash, el Adobe Media Server, el Wowza Media Server y el Red5 Media Server. La decisión sobre cuál usar fue fácil, el Adobe cuesta cerca de 2,000 dólares, el Wowza cuesta 5 dólares por día y Red5 es gratis.

La razón por la que decidí escribir esto (aparte de un poco de motivación externa) es por lo que pasó después de decidir el servidor multimedia que usaría. La documentación de Red5 es muy mala, los tutoriales son muy pocos y son menos aún en español.

Bueno, basta de introducciones y razones aburridas. A lo que todos vienen, el código.

Requerimientos


Yo no hice en sí una aplicación de Red5 (se puede automatizar ciertas cosas y posprocesar), todo lo hice con flash y la aplicación vod que trae Red5 por default. También algo importante es que este código te va a crear dos archivos separados, uno de audio y otro de video, esto es porque flash tiene problemas al sincronizar ambos y cuando lo hace automáticamente suelen estar desfazados. Para corregir este problema utilicé ffmpeg que pueden conseguir de aquí.

Esta parte sólo la voy a poner para que la puedan copiar, pero no la explicaré porque sólo son los imports y la declaración de variables.

ActionScript

package red5.CaptureVideo
{
 import flash.display.Sprite;
 import flash.display.LoaderInfo;
 import flash.events.Event;
 import flash.events.MouseEvent;
 import flash.events.NetStatusEvent;
 import flash.events.TimerEvent;
 import flash.media.Video;
 import flash.media.Camera;
 import flash.events.StatusEvent;
 import flash.media.Microphone;
 import flash.net.NetConnection;
 import flash.net.NetStream;
 import flash.utils.Timer;
 
 /**
  * ...
  * @author Luis García
  */
 public class Main extends Sprite
 {
  private var video:Video;
  private var audioPlaybackVideo:Video;
  private var camera:Camera;
  private var mic:Microphone;
  private var btnRecord:RecordButton;
  private var activeConnection:NetConnection;
  private var host:String = "rtmp://localhost/vod";
  
  private var activeStream:NetStream;
  private var audioOnlyStream:NetStream;
  private var currentlyRecordedFileName:String = "";
  
  private var bufferCheckTimer:Timer;
  private var stopRecordTimer:Timer;
  private var recordingHalted:Boolean;
  
El método Main es la que se corre al inicio del programa, lo único que hace es esperar a que se cargue el stage y cuando ya está se manda a llamar el método init.
  public function Main():void
  {
   if (stage)
    init();
   else
    addEventListener(Event.ADDED_TO_STAGE, init);
  }
  
El método init es el que empieza a inicializar las cosas que se verán en la pantalla y la cámara y el micrófono para grabar.
  private function init(e:Event = null):void
  {
   //Se inicializan los elementos que se despliegan en la pantalla
   removeEventListener(Event.ADDED_TO_STAGE, init);
   
   //Botón del tipo RecordButton que es una clase creada por mí. Al final la muestro.
   addButton();
   
   //Hacer la conexión al servidor red5
   setupConnection();

   //Habilitar la cámara y el micrófono
   setupCameraAndMic(480, 320);
  }
  
El método addButton se encarga de agregar el botón al stage y le agrega el evento para grabar.
  function addButton():void
  {
   btnRecord = new RecordButton();
   btnRecord.x = 230;
   btnRecord.y = 330;
   addChild(btnRecord);
   btnRecord.visible = true;
   //El evento que va a empezar a grabar lo de la cámara
   btnRecord.addEventListener(MouseEvent.CLICK, recordClick);
  }
  
El evento de grabar que crea los flujos de datos de audio y video y les asigna la cámara y el micrófono para que los capturen.
  function recordClick(event:MouseEvent):void
  {
   //Si el botón dice record, significa que no está grabando ya y que está listo para grabar
   if (btnRecord.text.text.toLowercase() == 'record')
   {
    recordingHalted = false;
    //Le cambia el texto al botón para no poner a grabar dos veces
    btnRecord.text.text = 'STOP';
    //Este timer es opcional, lo que hace es que termine de grabar a cierto tiempo en milisegundos.
    //Yo a este le puse que grabara 7.5 segundos y que después de eso mandara a parar la grabación
    stopRecordTimer = new Timer(7500, 1);
    stopRecordTimer.addEventListener(TimerEvent.TIMER, stopRecord);
    stopRecordTimer.start();

    //Crea los flujos de datos que va a mandar al servidor red5
    setupStream();

    //Le asigna a los flujos de datos la cámara y el micrófono para que de ahí agarren la información
    setupCameraAndMicStreams();
    
   }
  }
  
Para los flujos de datos y les quita la cámara y el micrófono para que no sigan consumiendo recursos.
  private function stopRecord(e:TimerEvent) {
   //Le cambia de nuevo el texto a Record para que pueda volver a grabar
   btnRecord.text.text = 'RECORD';
   recordingHalted = true;
   
   //Les quita la cámara y el micrófono a los flujos de datos
   activeStream.attachCamera (null);
   activeStream.attachAudio(null);
   audioOnlyStream.attachCamera(null);
   audioOnlyStream.attachAudio(null);
   //Quita el evento de parar de grabar (opcional si no se le pone el timer para grabar)
   stopRecordTimer.removeEventListener(TimerEvent.TIMER, stopRecord);
   stopRecordTimer.stop();
   stopRecordTimer = null;
  }
  
Crea la conexión al servidor de red5 y crea un evento para ver el estado de la conexión
  private function setupConnection():void
  {
   //Si no existe ya una conexión se tiene que crear una
   if (activeConnection == null)
   {
    activeConnection = new NetConnection();
    //Se asigna el cliente a la pantalla para que cuando el servidor intente hacer llamados de funciones se vaya a este programa
    activeConnection.client = this;
    activeConnection.addEventListener(NetStatusEvent.NET_STATUS, handleConnectionStatus, false, 1, true);
    //Se conecta al servidor asignado en la variable host
    activeConnection.connect(host);
   }
   else
   {
    //Si sí existe una conexión, se destruye y se vuelve a crear
    destroyConnection(true);
   }
  }
  
Destruye la conexión y si le pasan verdadero como parámetro la vuelve a crear.
  private function destroyConnection(_OptionalCallback:Boolean = false):void
  {
   //Si existe una conexión se destruye y si se pasa como true el parámetro, se manda a crear otra
   if (activeConnection != null)
   {
    activeConnection.close();
    activeConnection.removeEventListener(NetStatusEvent.NET_STATUS, handleConnectionStatus);
    activeConnection = null;
    
    if (_OptionalCallback == true)
    {
     setupConnection();
    }
   }
  }
  
El método de evento para ver si sí se conectó o no y por qué.
  private function handleConnectionStatus(e:NetStatusEvent):void
  {
   //Despliega en la consola el estado de la conexión
   trace("handleConnectionStatus - " + e.info.code);
   switch (e.info.code)
   {
    case 'NetConnection.Connect.Success': 
     trace("Successfully connected to: " + host);
     
     break;
    case 'NetConnection.Connect.Closed': 
     trace("Netconnection Closed to: " + host);
     break;
   }
  }
  
Crea un timer para ver si ya se limpió el buffer cuando se termina de grabar.
  
  private function createBufferCheckTimer():void
  {
   //Verifica que el buffer no exista para crearlo
   if (bufferCheckTimer == null)
   {
    bufferCheckTimer = new Timer(100);
    //Evento para manejar el buffer y no quitar el flujo de datos hasta que se haya despejado completamente el buffer
    //Esto se hace con el propósito de no perder información
    bufferCheckTimer.addEventListener(TimerEvent.TIMER, handleBufferCheck, false, 0, true);
    bufferCheckTimer.start();
   }
   else
   {
    //Destruye el buffer y lo vuelve a crear
    destroyBufferCheckTimer(true);
   }
  }
  
Destruye el timer del buffer.
  
  private function destroyBufferCheckTimer(_OptionalCallback:Boolean = false):void
  {
   //Si el buffer no es nulo lo destruye
   if (bufferCheckTimer != null)
   {
    bufferCheckTimer.stop();
    bufferCheckTimer.removeEventListener(TimerEvent.TIMER, handleBufferCheck);
    bufferCheckTimer = null;
    
    if (_OptionalCallback == true)
    {
     createBufferCheckTimer();
    }
   }
  }
  
Revisa si el buffer ya está limpio para cerrar los flujos de datos.
  
  private function handleBufferCheck(e:TimerEvent):void
  {
   //Si el flujo de video existe
   if (activeStream != null)
   {
    trace("Buffer = " + String(activeStream.bufferLength));
    //Si ya se paró la grabación
    if (recordingHalted == true)
    {
     trace("halt = true");
     //Hasta que el buffer del micrófono y del audio sea 0 se cierran las conexiones
     if ((activeStream.bufferLength == 0) && (audioOnlyStream.bufferLength == 0))
     {
      activeStream.close();
      audioOnlyStream.close();
      
      bufferCheckTimer.stop();
      bufferCheckTimer.removeEventListener(TimerEvent.TIMER, handleBufferCheck);
      bufferCheckTimer = null;
      
      
     }
    }
    else
    {
     trace("halt = false");
    }
   }
   //Reinicia el timer para revisar el buffer
   if (bufferCheckTimer != null)
   {
    bufferCheckTimer.reset();
    bufferCheckTimer.start();
   }
  }
  
Crea los flujos de datos para la conexión del servidor ya creada.
  
  private function setupStream():void
  {
   //Si los flujos de datos para video y audio no existen los crea con la conexión activa
   if (activeStream == null)
   {
    activeStream = new NetStream(activeConnection);
    audioOnlyStream = new NetStream(activeConnection);
    
    //Se les asigna el cliente a this para que todos los llamados a funciones los mande a este programa
    activeStream.client = this;
    audioOnlyStream.client = this;
    
    activeStream.addEventListener(NetStatusEvent.NET_STATUS, handleStreamStatus, false, 0, true);
    audioOnlyStream.addEventListener(NetStatusEvent.NET_STATUS, handleAudioOnlyStreamStatus, false, 0, true);
    
    //El número de segundos asignados al buffer
    activeStream.bufferTime = 100;
    audioOnlyStream.bufferTime = 100;
   }
   else
   {
    destroyStreams(true);
   }
  }
  
Maneja el estado del flujo de datos de video.
  
  private function handleStreamStatus(e:NetStatusEvent):void
  {
   //Despliega en consola el estado del flujo de video
   switch (e.info.code)
   {
    case 'NetStream.Buffer.Empty': 
     trace("Video Netstream Buffer Empty");
     break;
    case 'NetStream.Buffer.Full': 
     trace("Video Netstream Buffer Full");
     break;
    case 'NetStream.Buffer.Flush': 
     trace("Video Netstream Buffer Flushed!!!!");
     break;
   }
  }
  
Maneja el estado del flujo de datos de audio.
  
  private function handleAudioOnlyStreamStatus(e:NetStatusEvent):void
  {
   //Despliega en consola el estado del flujo de audio
   switch (e.info.code)
   {
    case 'NetStream.Buffer.Empty': 
     trace("Audio Netstream Buffer Empty");
     break;
    case 'NetStream.Buffer.Full': 
     trace("Audio Netstream Buffer Full");
     break;
    case 'NetStream.Buffer.Flush': 
     trace("Audio Netstream Buffer Flushed!!!");
     break;
   }
  }
  
Destruye los flujos de datos y si le pasas un booleano verdadero como parámetro los vuelve a crear.
  
  private function destroyStreams(_OptionalCallback:Boolean = false):void
  {
   //Destruye los flujos de datos si sí existen
   if (activeStream != null)
   {
    
    if (video != null)
    {
     video.attachNetStream(null);
    }
    if (audioPlaybackVideo != null)
    {
     audioPlaybackVideo.attachNetStream(null);
    }
    
    activeStream.attachCamera(null);
    activeStream.attachAudio(null);
    
    audioOnlyStream.attachCamera(null);
    audioOnlyStream.attachAudio(null);
    
    activeStream.close();
    audioOnlyStream.close();
    
    activeStream.removeEventListener(NetStatusEvent.NET_STATUS, handleStreamStatus);
    audioOnlyStream.removeEventListener(NetStatusEvent.NET_STATUS, handleAudioOnlyStreamStatus);
    
    activeStream = null;
    audioOnlyStream = null;
    
    if (_OptionalCallback == true)
    {
     setupStream();
    }
   }
  }
  
Configura la cámara y el micrófono dentro del stage y los asigna a un componente de video para desplegarse en la aplicación.
  
  function setupCameraAndMic(w:int, h:int):void 
  {
   //Se obtiene la cámara predeterminada por la configuración flash
   camera = Camera.getCamera();
   camera.addEventListener(StatusEvent.STATUS, camStatusHandler);
   //Se le asigna una altura, anchura y un framerate. En este caso se le pasa el de la animación.
   camera.setMode(w, h, stage.frameRate);
   //Se le asigna una calidad en base a 100.
   camera.setQuality(0, 95);
   
   //Se obtiene el micrófono predeterminado por la configuración flash
   mic = Microphone.getMicrophone();
   //Si sí exite uno se le asignan variables de sonido.
   if (mic != null)
   {
    mic.rate = 44;
    mic.gain = 50;
    mic.setSilenceLevel(0);
   }
   
   //Se crea el video que va a mostrarse en la pantalla con la misma anchura y altura que la cámara
   video = new Video(w, h);
   video.x = 10;
   video.y = 10;
   //Se le agrega la cámara para desplegarse
   video.attachCamera(camera);
   
   //Se agrega el video a la pantalla
   addChild(video);
  }
  
Les asigna a los flujos de datos la cámara y el micrófono ya creados.
  
  function setupCameraAndMicStreams():void
  {
   //Si ya se creó el flujo de video se le agrega la cámara
   if (activeStream != null)
   {
    activeStream.attachCamera(camera);
   }
   //Si ya se creó el flujo de audio se le agrega el micrófono
   if (audioOnlyStream != null)
   {
    audioOnlyStream.attachAudio(mic);
   }
   
   finishCamAndMicSetup();
  }
  
Se crean los nombres que se les va a asignar a cada uno de los archivos y se publican con esos nombres, uno para audio y otro para video. Se crean los dos archivos separados porque ActionScript y Red5 tienen problemas con la sincronización de los dos flujos y eso se hace manualmente con ffmpeg.
  
  private function finishCamAndMicSetup():void
  {
   //Si ya existe el flujo de datos se asignan nombres para los archivos de video y audio que se van a crear
   if (activeStream != null)
   {
    
    var tempDate:Date = new Date();
    //Se usa una fecha para obtener números cualquiera y tener un nombre aleatorio
    var uniqueFileName:String = "RecordTest_" + String(tempDate.getMinutes()) + String(tempDate.getMilliseconds());
    
    currentlyRecordedFileName = uniqueFileName;
    
    //El método publish de los flujos NetStream manda a llamar a la conexión asignada y crea en el servidor los archivos.
    //En este ejemplo se utilizó la aplicación vod de red5 que ya viene instalada, por lo tanto los video se guardaran en el folder de instalación de red5 bajo webapps/vod/streams
    activeStream.publish(uniqueFileName + "_Video", "record");
    audioOnlyStream.publish(uniqueFileName + "_Audio", "record");
    
    //Manda a verificar que el buffer se vacíe correctamente antes de cerrar la conexión
    createBufferCheckTimer();
   }
  }
  
Despliega en consola el estado de la cámara.
  
  function camStatusHandler(event:StatusEvent):void
  {
   trace(event.code);
  }
  
Al final se agregan estas dos funciones para que funcione NetConnection.
  
  //Estas funciones se usan sólo para la conexión a red5. El NetConnection los necesita.
  public function onBWDone(... rest):void
  {
   var p_bw:Number;
   if (rest.length > 0)
   {
    p_bw = rest[0];
   }
   trace("bandwidth = " + p_bw + " Kbps.");
  }
  
  public function onBWCheck(... rest):void
  {
   trace("wooo!")
  }
 
 }

}
Esta es la clase que utilicé para crear el botón. No es nada del otro mundo, sólo le pongo un texto, la posiciono y le dibujo un rectángulo.
package red5.CaptureVideo 
{
 import flash.display.Sprite;
 import flash.text.TextField;
 import flash.text.TextFormat;
 
 /**
  * ...
  * @author Luis garcia
  */
   
 public class RecordButton extends Sprite 
 {  
  var text:TextField;
  
  public function RecordButton() 
  {
   text = new TextField();
   text.text = "RECORD";
   text.x = 60;
   text.y = 10;
   text.width = 120;
   graphics.beginFill(0x808080);
   graphics.drawRect(0, 0, 120, 30);
   graphics.endFill();
   addChild(text);
  }
  
 }

}


Red5

Terminando el código de flash viene lo que hizo que tardara más tiempo, escoger la versión correcta de Red5 para que funcione bien el código que acabas de escribir. En las versiones más nuevas de Red5 hay unos problemas con la publicación de archivos al servidor, entonces se necesita usar la versión RC1.0 para que pueda funcionar correctamente. Esta versión se baja de aquí.
y para correrla sólo se necesita buscar red5.bat y correrlo como administrador (por lo de los permisos de escritura y de usar puertos).

Para probar si está funcionando se pone en cualquier navegador la dirección http://localhost:5080/  y si aparece algo de red5, significa que todo está bien. Ahora sólo falta probar la aplicación, ahí les avisa (en consola) si sí se pudo conectar correctamente. Para verificar si sí graba algo sólo se necesita ir al lugar donde está red5 (en donde encontraron red5.bat) y meterse a webapps/vod/streams, ahí debe de haber dos videos que dicen RecordTest_(número aleatorio)_Audio.flv y _Video.flv.
Para mezclarlos en un solo archivo, se necesita bajar ffmpeg de aquí y se usa en consola escribiendo lo siguiente:

ffmpeg -y -i PathDeRed5/webapps/vod/streams/RecordTest_(Números)_Video.flv -i  PathDeRed5/webapps/vod/streams/RecordTest_(Números)_Audio.flv  PathDeRed5/webapps/vod/streams/RecordTest_(Números)_Merged.flv

Y ahora el archivo que dice Merged es el que va a tener ambos canales juntos.

Espero este tutorial les haya servido para poder grabar un video y audio a un servidor.

3 comentarios:

  1. Me puedes hacer el favor de enviarme las clases y el .fla

    ResponderEliminar
  2. Buenas noches, tengo una inquietud, porque todas las funciones inician en la línea 1. Son clases independientes????? Podrias compartir los archivlos en un ..RAR o ZIP. Gracias

    ResponderEliminar