Building an application around Zencoder, Part 1: Using WebSockets for notifications

Video is complicated. If you’ve ever used FFMPEG on the command line, you know the number of flags available can be staggering. Zencoder allows developers to focus on building applications around video without needing to build (or scale) their own encoding solution from scratch.

That being said, developing a full-featured application is difficult, and doing so around Zencoder is no exception. This is the first post in a series that will cover the basics of developing an application around Zencoder’s API by doing just that: building an application. Because of the breadth of the material we’ll be covering, a basic understanding of web development is assumed.

Implementing updates

In order to show your users when a video is done encoding, you probably poll the Zencoder api for job progress. With WebSockets, however, you can just wait for Zencoder to send you a notification, then publish that notification directly to the front end.

By the end of this post we’ll have built a basic application that will send an API request to Zencoder, receive the notifications and update the front end via websockets based on those notifications. Because we’ll be using the resulting application for future posts as well, this post has detailed instructions on how to set up the application from start to finish. If all you’re interested in is the WebSockets implementation, feel free to skip ahead.

We’ll be using Node.js and Socket.io to create our demo, but you a have lot of options these days when it comes to realtime. If you don’t want to worry about building out the messaging backend, there are services such as PubNub, Pusher, and Firebase.

Setting up the server

In order to get started, you’ll need to have Node.js and NPM installed. NPM comes with Node now, so if you already have Node installed you probably have NPM. You can check by running npm in your terminal and if you see some usage instructions you’re good to go.

NPM Terminal Output

We’ll be using Express for our server, which if you come from the Ruby world is pretty similar to Sinatra. Start by installing Express: $ npm install express -g. This will install Express globally so you can use it to generate a new Express project by running $ express project/path

Express generate

Now that we have a starting point for our backend, let’s go ahead and add the NPM packages we’ll need to our package.json. cd into the project directory and open up package.json. You should see something like this:

{
  "name": "application-name",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node app"
  },
  "dependencies": {
    "express": "3.1.0",
    "jade": "*"
  }
}

Change the application name and add the Socket.io and Zencoder packages to the dependencies.

{
  "name": "zensockets",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node app"
  },
  "dependencies": {
    "express": "3.1.0",
    "jade": "*",
    "socket.io": "0.9",
    "zencoder": "0.2.x"
  }
}

Running $ npm install will install the new dependencies you just added. If everything went ok, you should now have a newly created node_modules directory containing all of the dependencies.

Now it’s time to start editing the server itself. Open up app.js and require socket.io and zencoder. The boilerplate chains http.createServer() and listen() at the end of the script, but we’re going to set things up a little differently to account for Socket.io. Go ahead and remove this from the end of app.js:

http.createServer(app).listen(app.get('port'), function(){
  console.log("Express server listening on port " + app.get('port'));
});

Now set up the requirements for Zencoder and Socket.io. Your dependencies chain should now look something like this:

var express = require('express')
  , routes = require('./routes')
  , user = require('./routes/user')
  , http = require('http')
  , path = require('path')
  , Zencoder = require ('zencoder')
  , app = express()
  , server = require('http').createServer(app)
  , io = require('socket.io').listen(server);

Now we need to add listen() back to the end, but now we can use the new server variable.

server.listen(app.get('port'), function(){
  console.log("Zensockets started on port " + app.get('port'));
});

You should now be able to run $ node app and fire up the server.

Start the server

Give me something to look at

In order to get things up and running quickly without having to do much styling, I used Bootstrap via Bootstrap CDN. Open up views/layout.jade so we can add them, along with jQuery and Socket.io’s client side library.

// Set the doctype to HTML5
doctype 5
html
  head
    title= title
    link(href='//netdna.bootstrapcdn.com/twitter-bootstrap/2.2.2/css/bootstrap.no-icons.min.css', rel='stylesheet')
    link(href='//netdna.bootstrapcdn.com/font-awesome/3.0.2/css/font-awesome.css', rel='stylesheet')
    link(rel='stylesheet', href='/stylesheets/style.css')
    script(src='http://code.jquery.com/jquery.min.js')
    script(src='/socket.io/socket.io.js')
    script(src='/javascripts/script.js')

    // Video.JS Requirements
    link(href='http://vjs.zencdn.net/c/video-js.css', rel='stylesheet')
    script(src='http://vjs.zencdn.net/c/video.js')

    block header
  body
    .container
      block content

You may notice there is no /javascripts/script.js file yet, but we’ll make it soon enough. First, let’s add a little more to our index view (views/index.jade). All we need to see when we first load the page is a text input for the location of the movie to be encoded and a button to kick things off. The notifications and outputs divs will be used later.

extends layout

block content
  h1= title

  .input-prepend.input-append
    span.add-on
      i.icon-film
    input(type='text', value='s3://zencodertesting/test.mov', placeholder='my input file', class='span5', id='input-file')
    a(type='submit', class='btn btn-success', id='start') Encode!

  .row
    .span12
      #notifications

      #outputs

Reload your browser window and you should see a nicely styled, simple form.

First View

If you’re wondering where “Express” is coming from, there’s an h1 that renders the variable title in the index view. You can change this in routes/index.js

exports.index = function(req, res){
  res.render('index', { title: 'Zensockets!' });
};

If you change this and refresh your browser, you’ll notice…nothing. That’s because you need to restart your server. ctrl+c your node application and restart it. You should now see the new title.

Any time you make a change to pretty much anything that’s not a view, you’ll need to restart your server for it to take effect. Since we’re about to start mucking around with app.js a lot more, now’s a good time to install nodemon, which will watch your application’s directory for changes and restart the server when they occur. Install it just like you did Express: $ npm install nodemon -g. Now you can start your server by running $ nodemon app.js.

Send some data through WebSockets

Before we start creating Zencoder jobs left and right, let’s test out our WebSockets a little and get to know them. In app.js, emit a message to the namespace “system” every time a new connection occurs.

io.sockets.on('connection', function (socket) {
  socket.emit('system', { message: 'Someone connected!' });
});

Now we need to handle receiving this message on the client side. Go ahead and create that public/javascripts/script.js file now and log any message sent to the namespace “system”.

$(function() {
  var socket = io.connect('http://' + location.hostname + ':' + location.port);

  socket.on('system', function (data) {
    console.log(data);
  });
});

Now when you restart your browser, you should see an object with “Connected!” being logged in your browser’s console.

Start Sending API requests to Zencoder

We’ll be using the Zencoder Node library we added as a dependency when we created our application to interact with the Zencoder API. Before doing anything, we’ll need to create a new Zencoder client using a valid API key. I placed this just below all of the module dependencies.

var zc = new Zencoder('YOUR API KEY');

Now create a post route that actually submits a create job request to Zencoder. The outputs will be hardcoded for the most part, but we’ll want to set the input to the value of our text input. First let’s make the “Encode!” button submit a post request to the backend in script.js.

$(function() {
  var socket = io.connect();

  socket.on('system', function (data) {
    console.log(data);
  });

  /* Encode! button */
  $('#start').click(function() {
    var input = $('#input-file').val();
    var request_body = { input_file: input };

    $.post('/submit-job', request_body, function(data) {
      console.log('Sent job request...');
    });
  });
});

Now add the route to app.js. Since we’re already listening to the “system” namespace on the client side, we’ll publish some information about our new job to it.

app.post('/submit-job', function(req, res) {
  zc.Job.create({
    test: true,
    input: req.body.input_file,
    outputs: [
      {
        label: 'MP4',
        url: 's3://your-awesome-s3-bucket/output.mp4',
        public: true,
        thumbnails: {
          number: 1,
          base_url: 's3://your-awesome-s3-bucket/',
          filename: 'webm_{{number}}',
          public: true
        }
      },
      {
        label: 'WebM',
        url: 's3://your-awesome-s3-bucket/output.webm',
        public: true,
        thumbnails: {
          number: 1,
          base_url: 's3://your-awesome-s3-bucket/',
          filename: 'webm_{{number}}',
          public: true
        }
      },
      {
        label: 'instant-play',
        instant_play: true,
        type: 'segmented',
        base_url: 's3://your-awesome-s3-bucket/',
        headers: {
          'x-amz-acl': 'public-read'
        },
        access_control: [
          {
            permission: 'FULL_CONTROL',
            grantee: 'aws@zencoder.com'
          },
          {
            permission: 'READ',
            grantee: 'http://acs.amazonaws.com/groups/global/AllUsers'
          }
        ]
      }
    ]
  }, function(err, data) {
    // Check for any errors first!
    if (err) {
      console.log(err);
      io.sockets.emit('system', {error: true, message: 'Something has gone terribly wrong...'});
      return;
    }
    io.sockets.emit('system', {message: 'Job created!', job_id: data.id, outputs: data.outputs})
  });
  res.send(200, {message: 'Success!'});
});

We don’t want to have to wait for the Zencoder response before responding to the user, so we send the request to Zencoder and immediately return a 200 response, then if the job is successfully created we return that to the client via WebSockets. Refresh your browser and with your developer console open click “Encode!”. You should see “Sent job request…” followed shortly by an object, which if the job was successful, will contain a job_id and an array of outputs.

When you look at the request, you can see the same output base url is repeated multiple times. To make updating the output location easier down the road, let’s move that into a config file along with some other variables. Two things we know might change are the Zencoder API key and the output url, so we’ll put those in a file called “config.js”.

var config = {}
config.apiKey = 'YOUR ZENCODER API KEY';
config.outputUrl = 's3://your-awesome-s3-bucket/';

module.exports = config;

Now we just need to require that file in our app, and we can access anything the variables wherever we need them. Our require block should now look like this:

var express = require('express')
  , routes = require('./routes')
  , user = require('./routes/user')
  , http = require('http')
  , path = require('path')
  , config = require('./config')
  , Zencoder = require ('zencoder')
  , app = express()
  , server = require('http').createServer(app)
  , io = require('socket.io').listen(server);

Now in your request, instead of url: 's3://your-awesome-s3-bucket/output.webm', you can use url: config.outputUrl + 'output.webm'. Since we have our API key in the config as well, you can use that when you create your zencoder client: var zc = new Zencoder(config.apiKey);

Keeping things to yourself

Now open up a few different browser windows and point them all to your local Zensockets instance with the console open. If you click encode on one of them, all of them should receive the same successful job message. This is because all connected browsers are listening to the same “system” namespace. We’ll fix this by creating a unique namespace and listen to that when a user creates a job.

app.post('/submit-job', function(req, res) {
  var random_string = Math.random().toString(36).substring(7);
  zc.Job.create({
  ...
  }, function(err, data) {
    if (err) {
      console.log(err);
      io.sockets.emit(random_string + '.job.create', {error: true, message: 'Something has gone terribly wrong...'});
      return;
    }
    io.sockets.emit(random_string + '.job.create', {message: 'Job created!', job_id: data.id, outputs: data.outputs});
  });
  res.send(200, {message: 'Success!', notification_namespace: random_string});
});

We generate random_string, which outputs something like “3lj1qi”. We then use that random_string as a part of our namespace, but in order to tell the client about it we also need to send it along in the initial 200 response. Now we need to update the client so that when that 200 response is initially returned it will start to listen on that socket namespace. Note: At this point while we’re still just testing to make sure we’re handling the response from Zencoder correctly, setting mock to true will return a response without creating a job.

zc.Job.create({
  mock: true,
  test: true,
  …

We now need update the client to listen to this new namespace specifically, so we’ll create a new function, jobSocket, which will start listening on our new, unique namespace and create an alert depending on the response from Zencoder. We’ll call jobSocket() when we successfully send our POST request to /submit-job so we can pass the response with the unique variable along.

/* Encode! button */
$('#start').click(function() {
  var input = $('#input-file').val();
  var request_body = { input_file: input };

  $.post('/submit-job', request_body, function(data) {
    jobSocket(data);
    console.log('Sent job request...');
  });
});

function jobSocket(response) {
  socket.on(response.notification_namespace, function(data) {
    console.log(data);
    if (data.type == 'job.create') {
      if (!data.error) {
        $('#notifications').append("<div class='alert alert-success'><strong>Job submitted!</strong> File is currently being encoded into " + data.outputs.count + " formats. <a href='https://app.zencoder.com/jobs/" + data.job_id + "' target='_blank'>View job</a></div>");
      } else {
        $('#notifications').append("<div class='alert alert-error'><strong>Request failed</strong> We were unable to create a job at this time. Sorry about that.");
      }
    } else {
      console.log('something else happened...')
    }
  });
}

At this point, if you refresh your browser and click “Encode!” you should get an alert saying the job was created. If you set mock to true, you’ll notice the “View Job” link is currently broken and the job / output IDs are all null.

Job Created

Time for actual notifications

We can use this same unique identifier for the rest of our notifications. Let’s create a new route in app.js that will accept Zencoder’s notifications and broadcast them to a unique namespace.

app.post('/notify/:id', function(req, res) {
  io.sockets.emit(req.params.id, req.body)
  res.send(204, {});
});

Now we need to update our Zencoder API request to include notifications sent to this new url, where the id parameter is our unique identifier. At this point, you’re probably wondering what to set the notification url to if you’re working locally. The Fetcher gem can work, and is currently being rewritten to better support a use case like this, but for now if you want to use it you’ll need to start fetcher with the unique notification URL each time (zencoder_fetcher -u localhost:3000/notify/3lj1qi API_KEY). You can also just point your client side javascript to zensockets.jit.su, which is running the final version of the code you’ve got but with CORS enabled. This will need to be changed in a two places in script.js. var socket = io.connect('http://zensockets.jit.su'); and $.post('http://zensockets.jit.su/submit-job', request_body, function(data) {. If you deploy your application online make sure to change both back to your server (probably just localhost). In the example below, I’m using the hosted Zensockets demo as the notification url to simplify working locally.

app.post('/submit-job', function(req, res) {
  var random_string = Math.random().toString(36).substring(7);

  var notification_url = 'http://zensockets.jit.su/notify/' + random_string;

  zc.Job.create({
    test: true,
    notifications: notification_url,
    input: req.body.input_file,
    outputs: [
      {
        label: 'MP4',
        url: config.outputUrl + 'output.mp4',
        notifications: notification_url,
        public: true,
        thumbnails: {
          number: 1,
          base_url: config.outputUrl,
          filename: 'webm_{{number}}',
          public: true
        }
      },
      {
        label: 'WebM',
        url: config.outputUrl + 'output.webm',
        notifications: notification_url,
        public: true,
        thumbnails: {
          number: 1,
          base_url: config.outputUrl,
          filename: 'webm_{{number}}',
          public: true
        }
      },
      {
        label: 'instant-play',
        instant_play: true,
        type: 'segmented',
        base_url: config.outputUrl,
        notifications: [
          {
            event: 'first_segment_uploaded',
            url: notification_url
          },
          {
            event: 'seamless_playback',
            url: notification_url
          },
          {
            url: notification_url
          }
        ],
        headers: {
          'x-amz-acl': 'public-read'
        },
        access_control: [
          {
            permission: 'FULL_CONTROL',
            grantee: 'aws@zencoder.com'
          },
          {
            permission: 'READ',
            grantee: 'http://acs.amazonaws.com/groups/global/AllUsers'
          }
        ]
      }
    ]
  }, function(err, data) {
    if (err) {
      io.sockets.emit(random_string, {error: true, type: 'job.create', message: 'Something has gone terribly wrong...'});
      return;
    }
    io.sockets.emit(random_string, {type: 'job.create', message: 'Job created!', job_id: data.id, outputs: data.outputs})
  });
  res.send(200, {message: 'Success!', notification_namespace: random_string});
});

However you decide to do it, at this point if you refresh your browser and click “Encode!”, you should see notifications show up in your console. All that’s left is to reflect these changes in the view. Below is the final version of my script.js, but you can use your imagination as to how to display the notifications.

$(function() {

  // 'http://localhost' if you're deploying your own application. We need to specify port 80 for io.connect or it will default to whatever port you're using for development
  var serverPath = 'http://zensockets.jit.su';
  var socket = io.connect(serverPath, {port: 80});

  socket.on('system', function (data) {
    console.log(data);
  });

  /* Encode! button */
  $('#start').click(function() {
    var input = $('#input-file').val();
    var request_body = { input_file: input };

    $.post(serverPath + '/submit-job', request_body, function(data) {
      jobSocket(data);
      console.log('Sent job request...');
    });
  });

  function jobSocket(response) {
    socket.on(response.notification_namespace, function(data) {
      if (data.type == 'job.create') { // Just the initial job created callback
        if (!data.error) {
          $('#notifications').append("<div class='alert alert-success'><strong>Job submitted!</strong> File is currently being encoded into " + data.outputs.count + " formats. <a href='https://app.zencoder.com/jobs/" + data.job_id + "' target='_blank'>View job</a></div>");
          $('#outputs').html("<h2>Outputs</h2><div class='row-fluid'><ul class='thumbnails'></ul></div>");
          $.each(data.outputs, function(key, value) {
            $('.thumbnails').append("<li class='span4 clearfix' id='" + value.id + "'><div class='thumbnail'><img src='http://placehold.it/1280x720'/1><div class='caption'><h4>" + value.label + "<a class='btn btn-small btn-warning pull-right' href='https://app.zencoder.com/output_media_files/" + value.id + " target='_blank'><i class='icon-spinner icon-spin loading'></i> Working</a></h4></div></div></li>");
          });
        } else {
          $('#notifications').append("<div class='alert alert-error'><strong>Request failed</strong> We were unable to create a job at this time. Sorry about that.");
        }
      } else { // Any other notification
        if (data.output && data.output.event) { // Output event notification (instant play)
          outputState(data.output.event, data);
          return;
        } else if (data.output) { // Output finished notification
          outputState(data.output.state, data);
          return;
        } else { // Job finished notification
          jobState(data);
        }
      }
    });
  }

  function jobState(notification) {
    var alert;
    switch(notification.job.state) {
      case 'failed':
        alert = "<div class='alert alert-error'><strong>Job Failed!</strong> Some of the outputs may have succeeded, but at least one failed.</div>";
        break;
      case 'processing':
        console.log('Job still in progress...');
        break;
      case 'finished':
        alert = "<div class='alert alert-success'><strong>Job Success!</strong> The job is officially completed.</div>";
        break;
    }
    return $('#notifications').append(alert);
  }

  function outputState(type, notification) {
    $('#' + notification.output.id + ' .loading').remove();

    if (notification.output.thumbnails) {
      $('#' + notification.output.id + ' .thumbnail img').attr('src', notification.output.thumbnails[0].images[0].url)
    }

    var btn   = $('#' + notification.output.id + ' h4 .btn');

    switch(type) {
      case 'failed':
        btn.removeClass().addClass('btn btn-small btn-danger pull-right').html('<i class="icon-warning-sign"></i> Failed');
        break;
      case 'finished':
        btn.removeClass().addClass('btn btn-small btn-success pull-right').html('<i class="icon-ok"></i> Done');
        break;
      case 'first_segment_uploaded':
        btn.removeClass().addClass('btn btn-small btn-info pull-right').html('<i class="icon-cloud-upload"></li> Uploading');
        $('#' + notification.output.id + ' .thumbnail img').remove();
        $('#' + notification.output.id + ' .thumbnail').prepend('<video id="hls_player" class="video-js vjs-default-skin" controls></video>');
        _V_("hls_player")
          .src({ src: notification.output.url, type: "application/x-mpegURL" })
          .volume(0.5)
          .ready(function(){
            var myPlayer = this;    // Store the video object
            var aspectRatio = 9/16; // Make up an aspect ratio

            function resizeVideoJS(){
              // Get the parent element's actual width
              var width = document.getElementById(myPlayer.id).parentElement.offsetWidth - 10; // the -10 is to take padding into account
              // Set width to fill parent element, Set height
              myPlayer.width(width).height( width * aspectRatio );
            }

            resizeVideoJS(); // Initialize the function
            window.onresize = resizeVideoJS; // Call the function on resize
          });
        break;
      case 'seamless_playback':
        btn.removeClass().addClass('btn btn-small btn-primary pull-right').html('<i class="icon-play-circle"></i> Play');
        _V_("hls_player").play();
        break;
    }
  }

});

I displayed each output and started playing the Instant Play output in Video.JS when the seamless_playback notification is received. For the webm and mp4 outputs I simply displayed the thumbnail we created in our API request.

Final browser

Keep in mind that, as of this writing, the flash fallback for Video.JS doesn’t support HLS, so the HLS version will only play in browsers that natively support it (Safari). You can find the source for the demo up to this point in the Zensockets GitHub repository.

blog comments powered by Disqus