Video Messaging in Rails

We've been working with our friends at the Chins Up Foundation to develop an innovative application connecting youth with college athletes through a pen-pal mentorship program. Recently, we were requested to add video messaging to the application. Here I'll take you step-by-step through the process of adding video messaging to your Rails 4.2.6 messaging application and get it up and running on Heroku.

Paperclip

Paperclip is an easy file management system for Rails apps. We will use it to store our audio/video files as attachments. Paperclip uses the AWS SDK gem to store the files on an AWS server. Paperclip-av-transcoder is a great audio/video transcoder for Paperclip that will convert your media files into a format that will work for Paperclip. Add the following gems to your Gemfile and run bundle on the command line:

gem 'paperclip', '~> 5.1.0'
gem 'aws-sdk', '~> 2.6'
gem 'paperclip-av-transcoder'

FFmpeg

We're also going to need to add FFmpeg to our local environment using Homebrew:

brew install ffmpeg --with-fdk-aac --with-ffplay --with-freetype --with-frei0r --with-libass --with-libvo-aacenc --with-libvorbis --with-libvpx --with-opencore-amr --with-openjpeg --with-opus --with-rtmpdump --with-schroedinger --with-speex --with-theora --with-tools

RecordRTC

RecordRTC is an entirely client-side JS library that can be used to record WebRTC audio/video media streams. To use, simply copy the recordrtc.js and whammy.js files into your vendor/assets/javascripts directory. Add them to your config/application.rb file:

config.assets.paths << Rails.root.join('vendor', 'assets', 'components')

Also require them in your assets/javascripts/application.js:

//= require recordrtc
//= require whammy

Add Attachment Video to Messages

rails g migration add_attachment_video_to_messages video:attachment
rake db:migrate

Now you should have 4 additional fields inside of your messages table if you check your schema.

Messages Model

Next lets add a little code to our models/message.rb to let our app know to expect and how to store video files.

has_attached_file :video,
                  styles: {
                    medium: {
                      geometry: '640x480',
                      format: 'mp4'
                    },
                    thumb: {
                      geometry: '160x120',
                      format: 'jpg',
                      time: 10
                    }
                  }, processors: [:transcoder]

validates_attachment_content_type :video, content_type: %r{\Avideo\/.*\z}
validates :message_not_blank_when_submitted

def message_not_blank_when_submitted
  if body.empty? && !video?
    errors.add(:body, 'must not be blank upon submit')
  end
end

Messages Controller

Next we need to update our controller to expect and route requests appropriately. In your controllers/messages_controller.rb file be sure to add :video to your accepted message_params.

def message_params
  params.require(:message).permit(:body, :video)
end

Messages Views

Last before we get into fun JavaScript land, we need to update some views to render these beautiful videos. First, lets create a space for our video to play once its created:

views/messages/show.html.haml

.body
  = message.body
- if message.video.exists?
    %video{controls: '', src: message.video }

Next, lets update our messages form to include videos!

views/messages/_form.html.haml

#video-message
  #players{style: 'text-align: center;'}
    %video.recorder{autoplay: '', loop: '', muted: '', height: '480', width: '640'}
    %audio.recorder{autoplay: '', loop: '', muted: ''}
  #buttons.actions{style: 'text-align: center; padding: 1em;'}
    %button#record_button{type: 'button', class: 'save-button'} Start Recording
    %button#play_button{type: 'button', class: 'send-button'} Play
    -if @can_submit_message
      %button#upload_button{type: 'button', class: 'send-button', title: "Are you sure you're ready to send this message?", data: {confirm: "You won't be able to edit anymore."}} Send

Video JS

Now that we have a Rails app set up, let's move on to work on the actual video! First we need to create assets/javascripts/video.js.

This functionality does not currently work in Safari and Internet Explorer. So, you'll want to suggest your user switches browsers.

Let's do some setup and set the video options:

var stream;
var audio_recorder = null;
var video_recorder = null;
var recording = false;
var playing = false;
var formData = null;

var videoOptions = {
  type: "video",
  video: {
    width: 640,
    height: 480
  },
  canvas: {
    width: 640,
    height: 480
  }
};

var constraints = { audio: true, video: { mandatory: {}, optional: []} }

The method for accessing the computer (or other device)'s camera and microphone is different depending on the type and version of the browser. The following code selects the appropriate method for accessing the user's camera and audio and passes in the constraints.

if (navigator.mediaDevices == undefined) {
  navigator.mediaDevices = {};
}

if (navigator.mediaDevices.getUserMedia === undefined) {
  navigator.mediaDevices.getUserMedia = function(constraints) {
    var getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

    if (!getUserMedia) {
      return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
    }

    return new Promise(function(resolve, reject) {
      getUserMedia.call(navigator, constraints, resolve, reject);
    });
  }
}

Now that we have the appropriate method for accessing the camera and microphone, let's record the video and audio (and handle errors)!

navigator.mediaDevices.getUserMedia(constraints).then(function(pStream) {

  stream = pStream;
  // setup video
  video = $("video.recorder")[0];

  video.src = window.URL.createObjectURL(stream);
  video.width = 640;
  video.height = 480;
  // init recorders
  audio_recorder = RecordRTC(stream, { type: "audio", bufferSize: 16384 });
  video_recorder = RecordRTC(stream, videoOptions);

  // update UI
  $("#record_button").show();
}).catch(function(err) {
  console.log(err.name + ': ' + err.message);
});

$("#record_button").click(function(){
  if (recording) {
    stopRecording();
  } else {
    pstream = null;
    stream = null;
    startRecording();
  }
});

var startRecording = function() {
  // record the audio and video
  video_recorder.startRecording();
  audio_recorder.startRecording();

  // update the UI
  $("#play_button").hide();
  $("#upload_button").hide();
  $("video.recorder").show();
  $("#video-player").remove();
  $("#audio-player").remove();
  $("#record_button").text("Stop recording");

  // toggle boolean
  recording = true;
}

We are now recording audio and video! Now we want to stop recording, display the video we recorded and prepare it for uploading.

var stopRecording = function() {
  // stop recorders
  audio_recorder.stopRecording();
  video_recorder.stopRecording();

  // set form data
  formData = new FormData();

  var audio_blob = [];
  var video_blob = [];
  function getAudio() {
    audio_blob = audio_recorder.getBlob();
    formData.append("audio", audio_blob);
  }

  function getVideo() {
    video_blob = video_recorder.getBlob();
    formData.append("video", video_blob);
  }
  var audio_player
  var video_player

  function setPlayers() {
    getAudio();
    getVideo();

    // add players
    video_player = document.createElement("video");
    video_player.id = "video-player";
    video_player.width = $('video.recorder').width();
    video_player.height = $('video.recorder').height();
    setTimeout(function() {
      video_recorder.getDataURL(function(dataURL) {
        video_player.src = dataURL;
      });
    }, 500);

    if ($('#video-player').length) {
      $('#video-player').remove();
    }
    $("#players").append(video_player);

    audio_player = document.createElement("audio");
    audio_player.id = "audio-player";
    setTimeout(function() {
      audio_recorder.getDataURL(function(dataURL) {
        audio_player.src = dataURL;
      });
    }, 500);
    if ($('#audio-player').length) {
      $('#audio-player').remove();
    }
    $("#players").append(audio_player);

  }
  setPlayers()

  // update UI
  $("video.recorder").hide();
  $("#play_button").show();
  $("#upload_button").show();
  $("#record_button").text("Re-Record")

  // toggle boolean
  recording = false;
}

At this point, you should be able to record audio and video and display the video player. Now we need to be able to interact with the video player:

$("#play_button").click(function(){
  if (playing) {
    stopPlayback();
  } else {
    startPlayback();
  }
});

var stopPlayback = function() {
  video = $("#video-player")[0];
  video.pause();
  video.currentTime = 0;
  audio = $("#audio-player")[0];
  audio.pause();
  audio.currentTime = 0;

  $("#play_button").text("Play");

  // toggle boolean
  playing = false;
}

var startPlayback = function() {
  video = $("#video-player")[0];
  video.play();
  audio = $("#audio-player")[0];
  audio.play();
  $("#video-player").bind("ended", stopPlayback);

  $("#play_button").text("Stop");

  // toggle boolean
  playing = true;
}

The last thing we need to do with our video is upload it to our Rails server:

$("#upload_button").click(function(){
  var audio_blob = audio_recorder.getBlob();
  var video_blob = video_recorder.getBlob();
  var data = new FormData();
  data.append('message[video]', video_recorder.getBlob(), (new Date()).getTime() + '.webm');
  data.append('message[audio]', audio_recorder.getBlob(), (new Date()).getTime() + '.webm');
  data.append('commit', 'Send');

  var oReq = new XMLHttpRequest();
  oReq.open('PATCH', $('.draft-form').attr('action'));
  oReq.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content'))
  oReq.send(data);
  oReq.onload = function(oEvent) {
    if (oReq.status == 200) {
      console.log('Uploaded');
      location.reload();
    } else {
      console.log('Error ' + oReq.status + ' occurred uploading your file.');
      location.reload();
    }
  }
});

Heroku

Now that we have our video messages working great locally, let's push it up to our Heroku instance so our users can use it! Assuming you've already created a Heroku instance with a Ruby buildpack, when you push your new code up to Heroku you will notice that you are unable to upload your video messages to the server. This is because you need to add the Ffmpeg buildpack! Heroku recently changed how to use multiple buildpacks, so make sure you use this method to ensure it works (until Heroku changes it again :-)):

First, check what buildpacks you are currently using. You should see heroku/ruby.

heroku buildpacks -r <app_name>

To add the ffmpeg buildpack, simply type:

heroku buildpacks:add https://github.com/znupy/heroku-buildpack-ffmpeg-x264-ogg -r <app_name>

In order to fully implement the new buildpack, you will need to deploy your code to Heroku. Now your video messaging should work like a charm! I hope this tutorial was easy to follow and informative. I look forward to your feedback!