Thursday, January 12, 2012

Local video/audio recording in Adobe AIR using embedded Red5 media server

Hi everyone.

Just wanna share some experience I got by investigating the possibility to develop
local recorder using Adobe AIR + Red5 media server .

You will ask,
why Adobe AIR? Because I have been working with Flex and AIR almost 4 years and i find these technologies very attractive for all - clients and developers.
Why Red5? Because it is free, easy to install and use.
Initially the task was to migrate web-based enterprise application which uses FMS remotely to standalone AIR app with own media server locally. But before, it was required to develop some proof of concept (POC) to understand all pro et contra.

Here is folders' structure of POC:
As you can see, folder server contains Red5 media server. Fortunately, Red5 doesn't require to be installed etc. You can just copy the server's folder and to run the server by red5.bat. Folder server is included in src folder and because it is not an embedded asset, it will be moved in bin-debug folder and after  app installation, Red5 will be in installation directory, exactly what we need!!!


In general, we should do next steps:

1. Start Red5 server on application initialization.

Starting from AIR2.6 (if i am not mistaken), AIR can run native processes. HERE is just perfect article what it is and how to use it.
In my code, Red5 starting up is implemented in next way (code a bit messy but for POC and for general understanding it is enough I guess):
package com.localrecordingred5
{
  import flash.desktop.NativeProcess;
  import flash.desktop.NativeProcessStartupInfo;
  import flash.events.DataEvent;
  import flash.events.Event;
  import flash.events.EventDispatcher;
  import flash.events.NativeProcessExitEvent;
  import flash.events.ProgressEvent;
  import flash.events.TimerEvent;
  import flash.filesystem.File;
  import flash.utils.Timer;
  
  import mx.controls.Alert;
  import mx.core.FlexGlobals;

  [Event(name="started", type="flash.events.Event")]
  [Event(name="stopped", type="flash.events.Event")]
  [Event(name="logging", type="flash.events.DataEvent")]
  public class Red5Manager extends EventDispatcher
  {
    private static const RED5_FOLDER:String = "server";
    
    private var logsProcessor:LogsProcessor = new LogsProcessor();
        
    private var forceCloseProcessTimer:Timer = new Timer(10000);
    private var startRed5Process:NativeProcess;
    
    /**
     * Constructor
     */
    public function Red5Manager() : void
    {
      logsProcessor.addEventListener("started", dispatchEvent);
      logsProcessor.addEventListener("shuttedDown", shuttedDown);
      logsProcessor.addEventListener("addressInUse", addressInUse);
    }
    
    private function shuttedDown(e:Event) : void
    {
      if (startRed5Process) startRed5Process.exit();
    }
    
    private function addressInUse(e:Event) : void
    {
      Alert.show("IP-port is busy. Please stop the process on port 1935.", "Ooops", 4, null, okHandler);
      
      function okHandler(e:Event) : void
      {
        stop();
      }
    }
        
    public function start() : void
    {
      launchRed5();
    }
    
    public function stop() : void
    {
      if (startRed5Process)
      {
        forceCloseProcessTimer.addEventListener(TimerEvent.TIMER, forcedExit);
        forceCloseProcessTimer.start();
        
        LocalRecordingRed5(FlexGlobals.topLevelApplication).blockUI("Shutting down...");
        
        shutdownRed5();
      }
      else
      {
        onProcessExit();
      }
      
      function forcedExit(e:TimerEvent) : void
      {
        startRed5Process.exit(true);
      }
    }
        
    public function launchRed5() : void
    {
      invokeRed5(true);
    }
    
    public function shutdownRed5() : void
    {
      invokeRed5(false);
    }
    
    private function invokeRed5(launch:Boolean) : void
    {
      var startupInfo:NativeProcessStartupInfo = new NativeProcessStartupInfo();
      startupInfo.executable = ConfigurationManager.getInst().getJavaFile();
      startupInfo.workingDirectory = File.applicationDirectory.resolvePath(RED5_FOLDER);
      
      var separator:String = ConfigurationManager.isWin ? ";" : ":";
      
      var arg5:String = File.applicationDirectory.resolvePath('server/boot.jar').nativePath;
        arg5 +=  separator;
        arg5 +=  File.applicationDirectory.resolvePath('server/conf').nativePath;
        arg5 +=  separator + "." + separator;
        
      var processArguments:Vector.<String> = new Vector.<String>();
      processArguments[0] = "-Dpython.home=lib";
      processArguments[1] = "-Dlogback.ContextSelector=org.red5.logging.LoggingContextSelector";
      processArguments[2] = "-Dcatalina.useNaming=true";
      processArguments[3] = "-Djava.security.debug=failure";
      processArguments[4] = "-cp";
      processArguments[5] = arg5;
      processArguments[6] = launch ? "org.red5.server.Bootstrap" : "org.red5.server.Shutdown";
      if (!launch)
      {
        processArguments[7] = "9999";
        processArguments[8] = "red5user";
        processArguments[9] = "changeme";
      }
      startupInfo.arguments = processArguments;
      
      startProcess(startupInfo);
    }
    
    private function startProcess(startupInfo:NativeProcessStartupInfo) : void
    {
      startRed5Process = new NativeProcess();
      startRed5Process.addEventListener(NativeProcessExitEvent.EXIT, onProcessExit);
      startRed5Process.addEventListener(ProgressEvent.STANDARD_ERROR_DATA, onError);
      startRed5Process.addEventListener(ProgressEvent.STANDARD_OUTPUT_DATA, onOutput);
      startRed5Process.start(startupInfo);
    }
    
    private function onProcessExit(e:Event = null) : void
    {
      dispatchEvent(new Event("stopped"));
    }    
    
    private function onError(event:ProgressEvent) : void
    {
      var process:NativeProcess = event.target as NativeProcess;
      var v:String = process.standardError.readUTFBytes(process.standardError.bytesAvailable);
      dispatchLogEvent(v);
    }
    
    private function onOutput(event:ProgressEvent) : void
    {
      var process:NativeProcess = event.target as NativeProcess;
      var v:String = process.standardOutput.readUTFBytes(process.standardOutput.bytesAvailable);
      dispatchLogEvent(v);
    }
    
    private function dispatchLogEvent(log:String) : void
    {
      logsProcessor.process(log);
      dispatchEvent(new DataEvent("logging", false, false, log));
    }
  }
}

I will not deep into details of each line of code, but major points are here.
Server should be started somehow from AIR.
In the beginning I tried to run .bat file with native process however it is impossible. Than i tried to use cmd.exe to start run.bat but this solution is also bad, because your code will be platform and even OS versions dependent. Finally, after investigation of run.bat content, I moved it in actionscript. See function invokeRed5 which emulates invocation of run.bat (to start the Red5 if boolean parameter is 'true') and red5-shutdown.bat (to shutdown the server if parameter is 'false'). NativeProcess dispatches all the logs from server by so called ProgressEvents. And it even separates Java errors and standard Java logs by different event names! ProgressEvent.STANDARD_ERROR_DATA and  ProgressEvent.STANDARD_OUTPUT_DATA.  Nice, ha?!... 
LogsProcessor.as processes the logs and dispatches appropriate events. It is done in a bit dirty way by checking the text matching however, right now, I don't have any better solution. If you find smarter way, please let me know.
package com.localrecordingred5
{
  import flash.events.Event;
  import flash.events.EventDispatcher;

  [Event(name="started", type="flash.events.Event")]
  [Event(name="shuttedDown", type="flash.events.Event")]
  [Event(name="addressInUse", type="flash.events.Event")]
  public class LogsProcessor extends EventDispatcher
  {
    private static const LAUNCHED:String = "Installer service created";
    private static const SHUTTED_DOWN:String = "Stopping Coyote";
    private static const ADDRESS_IN_USE:String = "Address already in use";
    
    public function process(log:String) : void
    {
      if (log.indexOf(LAUNCHED) > -1)
      {
        dispatchEvent(new Event("started", true));
      }
      if (log.indexOf(SHUTTED_DOWN) > -1)
      {
        dispatchEvent(new Event("shuttedDown", true));
      }
      if (log.indexOf(ADDRESS_IN_USE) > -1)
      {
        dispatchEvent(new Event("addressInUse", true));
      }
    }
  }
}

Oh... and don't forget to set this configuration in your AIR *-app.xml:
<supportedProfiles>extendedDesktop</supportedProfiles>


1.1. Check Java path

Notice, that executable file of native process is javaw.exe for Windows and java for Mac. AIR doesn't know where is Java installed, that's why you should check it once with user and save it in some configuration file.
Here are my ConfigurationManager and ConfigPopup which do all this stuff:

 ConfigurationManager.as 
package com.localrecordingred5
{
  import flash.display.DisplayObject;
  import flash.events.Event;
  import flash.events.EventDispatcher;
  import flash.filesystem.File;
  import flash.filesystem.FileMode;
  import flash.filesystem.FileStream;
  import flash.system.Capabilities;
  
  import mx.core.FlexGlobals;
  import mx.managers.PopUpManager;
  
  [Event(name="configured", type="flash.events.DataEvent")]
  public class ConfigurationManager extends EventDispatcher
  {
    private static const CONFIG_FILE:String = "config.xml";
      
    private static var instance:ConfigurationManager;
    
    private var configFile:File;
    private var _config:ConfigVO;
    private var configPopup:ConfigPopup;
    
    /**
     * Configuration
     * 
     */
    public function ConfigurationManager(enforcer:SingletonEnforcer)
    {
      if (!enforcer) throw new Error("ConfigurationManager can't be explicitly instantiated");
      
      configFile = File.applicationStorageDirectory.resolvePath(CONFIG_FILE);
    }
    
    public static function getInst() : ConfigurationManager
    {
      if (!instance)
        instance = new ConfigurationManager(new SingletonEnforcer());
      return instance;
    }
    
    //-----------------------------------------------------
    //
    //  PUBLIC methods
    //
    //-----------------------------------------------------
    public function get config() : ConfigVO
    {
      return _config;
    }
    
    /**
     * Check configuration.
     * If everything is configured, dispatch the event and continue app loading.
     * Otherwise, show config popup.
     */
    public function check() : void
    {
      var cfgXml:XML = readConfig();
      populateConfig(cfgXml);
      if (isJavaHomeValid())
      {
        dispatchEvent(new Event("configured"));
      }
      else
      {
        configPopup = PopUpManager.createPopUp(FlexGlobals.topLevelApplication as DisplayObject, ConfigPopup, true) as ConfigPopup;
        configPopup.addEventListener("saveConfig", save);
        PopUpManager.centerPopUp(configPopup);
      }
    }
        
    public function getJavaFile() : File
    {
      // Get a file reference to the JVM
      var file:File = new File(_config.javaHome);
      if (isWin)
      {
        file = file.resolvePath("bin/javaw.exe");
      }
      else
      {
        file = file.resolvePath("Home/bin/java");
      }
      return file;
    }
           
    private function save(e:Event) : void
    {
      var xml:XML = 
        <config>
          <javaHome>{config.javaHome}</javaHome>
        </config>;
      var fs:FileStream = new FileStream();
      fs.open(configFile, FileMode.WRITE);
      fs.writeUTFBytes(xml);
      fs.close();
      
      PopUpManager.removePopUp(configPopup);
      dispatchEvent(new Event("configured"));
    }
    
    private function populateConfig(configXml:XML) : void
    {
      _config = new ConfigVO();
      if (configXml)
      {
        _config.javaHome = new String(configXml..javaHome);
      }
    }
    
    public function isJavaHomeValid() : Boolean
    {
      if (!_config) return false;
      
      // If no known last home, present default/sample values
      if (!_config.javaHome) setDefaultJavaHome();
      
      return getJavaFile().exists;
      
      function setDefaultJavaHome() : void
      {
        _config.javaHome = (isWin) ? 
          "C:\\Program Files\\Java\\jre6" : 
          "/System/Library/Frameworks/JavaVM.framework/Versions/1.6.0";
      }
    }
        
    private function readConfig():XML
    {
      if (configFile.exists)
      {
        var fs:FileStream = new FileStream();
        fs.open(configFile, FileMode.READ);
        var xml:XML = XML(fs.readUTFBytes(fs.bytesAvailable));
        fs.close();
        return xml;
      }
      else
      {
        return null;
      }
    }
    
    public static function get isWin() : Boolean
    {
      return Capabilities.os.toLowerCase().indexOf("win") > -1;
    }
  }
}
class SingletonEnforcer{}

ConfigPopup.mxml
<?xml version="1.0" encoding="utf-8"?>
<s:Panel xmlns:fx="http://ns.adobe.com/mxml/2009" 
     xmlns:s="library://ns.adobe.com/flex/spark" 
     xmlns:mx="library://ns.adobe.com/flex/mx" 
     title="Configuration"
     creationComplete="init()">
  
  <fx:Script>
    <![CDATA[
      import mx.events.FlexEvent;
      import mx.utils.StringUtil;
      
      private var cfgMngr:ConfigurationManager;
      
      private function init():void
      {
        cfgMngr = ConfigurationManager.getInst();
        javaTI.text = cfgMngr.config.javaHome;
      }
      
      private function onSave():void
      {
        cfgMngr.config.javaHome = StringUtil.trim(javaTI.text);
        if (cfgMngr.isJavaHomeValid())
        {
          dispatchEvent(new Event("saveConfig"));
        }
        else
        {
          errLbl.text = "Java Home is not configured properly. \nPlease check again";
        }
      }
      
      
      
    ]]>
  </fx:Script>
  
  <s:layout>
    <s:VerticalLayout paddingBottom="0" paddingLeft="0" paddingRight="0" paddingTop="0"/>
  </s:layout>

  <s:Form width="100%" >
  
    <s:layout>
      <s:FormLayout gap="0" />
    </s:layout>
    
    <s:FormItem width="100%">
      <s:Label maxDisplayedLines="3" width="100%" color="#6c8dae"
           text="Please configure the application once and enjoy your local video recording."/>  
    </s:FormItem>
    <s:FormItem label="Java Home:" width="100%">
      <s:TextInput id="javaTI" width="100%"/>
    </s:FormItem>
    <s:FormItem width="100%">
      <s:layout>
        <s:VerticalLayout paddingTop="0"/>
      </s:layout>
      <mx:HRule width="100%"/>
      <s:HGroup width="100%" height="30">
        <s:Label id="errLbl" color="red" width="100%"/>
        <s:Button label="Save" click="onSave()"/>
      </s:HGroup>
    </s:FormItem>
  </s:Form>  
  
</s:Panel>


ConfigVO.as
package com.localrecordingred5
{
  [Bindable]
  public class ConfigVO
  {
    public var javaHome:String;
  }
}


2. Establish connection with Red5 from Flash.

Now, when server is up and running as a Java process, it is the time to establish NetConnection and to create NetStream.
URL connection is always rtmp://localhost:{configured port}/{name of app}. In my case, it is rtmp://localhost:1935/mytest/.

public function connect():void
{
  connection = new NetConnection();
  connection.addEventListener(NetStatusEvent.NET_STATUS, netConnectionStatusHandler);
  connection.connect(RTMP_SERVER);
}

private function netConnectionStatusHandler(event:NetStatusEvent) : void
{
  var evCode:String = event.info.code;
  
  switch (evCode)
  {
    case "NetConnection.Connect.Success":
      currentState = STOP_STATE;
      createStream();
      dispatchEvent(new Event("connected"));
      break;
    ...
  }
}
      
private function get cam() : Camera
{
  var camera:Camera = Camera.getCamera();
  camera.setQuality(0, qualityNS.value);
  var res:Object = resCB.selectedItem;
  camera.setMode(res.width, res.height, frameNS.value);
  return camera;
}

private function get mic() : Microphone
{
  return Microphone.getMicrophone();
}

private function createStream() : void
{
  var cam:Camera = this.cam;
  vd.attachCamera(cam);
  stream = new NetStream(connection);
  stream.addEventListener(NetStatusEvent.NET_STATUS, netConnectionStatusHandler);
  stream.addEventListener(AsyncErrorEvent.ASYNC_ERROR, onAsyncError);
  stream.attachCamera(cam);
  stream.attachAudio(mic);
  stream.bufferTime = BUFFER_TIME;
  createStreamName();
}

//create some random name for test
private function createStreamName() : void
{
  streamName = "video" + Math.round(Math.random() * 100000);
  dispatchLogEvent("Stream name generated: " + streamName + "\n");
}

As you can see from snippet above, on NetConnection.Connect.Success the stream is created.

Recording flow is primitive.
On record button click, the stream starts is published:
stream.publish(streamName, "record");

On record stop, the flag is set that stop is requested + camera and mic are unattached:
private function onStop() : void
{
  stream.attachCamera(null);
  stream.attachAudio(null);
  stopRequested = true;
  LocalRecordingRed5(FlexGlobals.topLevelApplication).blockUI("Processing...");
}

Application is waiting until stream's buffer is empty. Whet it is, stream should be closed:
case "NetStream.Buffer.Empty":
  if (stopRequested)
  {
    stop();
    LocalRecordingRed5(FlexGlobals.topLevelApplication).unblockUI();
    stopRequested = false;
    recording = false;
  }
  break;
  
  ...

private function stop():void 
{
  ...;
  stream.close();
}

Now, the last event in my flow - stream is unpublished successfully and I can enjoy my video.

case "NetStream.Unpublish.Success":
  var f:File = File.applicationDirectory.resolvePath("server/webapps/mytest/streams/" + streamName + ".flv");
  f.addEventListener(Event.SELECT, saveByPath);
  f.browseForSave(streamName + ".flv");
  break;

Notice, that Red5 writes the FLVs into directory 'streams' under your server application directory. I don't know how to configure this path but looks like this
investigation will be required in the nearest feature :)


3. Server's application 'mytest'

Regarding to directory 'mytest'... this is your, so called, web application. In few words, Red5 is based on Apache Tomcat web-server and 'mytest' can contain all your html assets, JSPs, compiled Java classes etc. In my case, 'mytest' contains configured by default web.xml (nothing special inside),
red5-web.properties
webapp.contextPath=/mytest
webapp.virtualHosts=localhost, 127.0.0.1

and red5-web.xml which is based on Spring
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:lang="http://www.springframework.org/schema/lang"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.0.xsd
                           http://www.springframework.org/schema/lang http://www.springframework.org/schema/lang/spring-lang-2.0.xsd">
  
  <bean id="placeholderConfig" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
      <property name="location" value="/WEB-INF/red5-web.properties" />
  </bean>
  
  <bean id="web.context" class="org.red5.server.Context" autowire="byType" />
  
  <bean id="web.scope" class="org.red5.server.WebScope"
     init-method="register">
    <property name="server" ref="red5.server" />
    <property name="parent" ref="global.scope" />
    <property name="context" ref="web.context" />
    <property name="handler" ref="web.handler" />
    <property name="contextPath" value="${webapp.contextPath}" />
    <property name="virtualHosts" value="${webapp.virtualHosts}" />
  </bean>
  
  <bean id="web.handler" class="com.test.MyTest" /> 
</beans>

Marked in red is integration of Java class.

This class should be extended from class org.red5.server.adapter.ApplicationAdapter.
All the public methods of this class can be invoked from AIR through the connection.
E.g. here is an invocation of public method pingServer of Java class com.test.MyTest from Flash:
protected function pingServer():void
{
  connection.call("pingServer", new Responder(res, fault));
  
  function res(e:Object) : void
  {
    dispatchLogEvent("Ping is successfull. Returned value:" + e.toString() + "\n");
  }
  
  function fault(e:Object) : void
  {
    dispatchLogEvent("Ping is failed.\n");
  }
}
This is fantastic feature cause you can move some heavy calculations, data processing etc. from AIR to Java. Incredible!


4. Never forget to shut down the server when application is closing.

...because on next application starting, you will get "port is busy" error.

To do this, the easiest way is to suppress the closing event and to put the logic which will stop the Red5-Java process and, only after that, will close the application.
closing="onClosing(event)"
...
...             
protected function onClosing(event:Event):void
{
  event.preventDefault();
  red5Starter.stop();
}
...
...
private function red5Stopped(event:Event):void
{
  NativeApplication.nativeApplication.exit();
}
...
...
<fx:Declarations>
  <localrecordingred5:Red5Manager id="red5Starter" 
                  logging="red5Logging(event)"
                  started="red5Started(event)"
                  stopped="red5Stopped(event)"/>
</fx:Declarations>


Summary
That's probably it what I wanted to share with wide Flex/AIR auditory.

Of course, code is not optimized and can contain some memory leaks etc. but for POC it is not necessary.

The source is here

In folder setup, you will find already compiled AIR installion file.

Separate thanks to Christophe Coenraets for his great article Tomcat Launcher: Sample Application using the AIR 2.0 Native Process API which helped me a lot to launch Red5 under AIR.

Cheers...