Self-Hosting a Video Using Video.js and HLS

For a project at 50onRed, I was recently faced with a new technical challenge. I was building a landing page for a video, and one of the requirements of this project was to self-host the video, rather than just uploading to YouTube and embedding on the page.

Before even starting the project, I knew that I was facing a lot of unknowns. Where would we host the video? How do you use an HTML5 Video element? How do I make sure the video will work on all browsers on all platforms? What can I do to make the video play consistently on a variety of connection speeds?

Along the way of completing this project, I made many mistakes and learned a great deal. While I was working, I was also keeping notes for myself about what worked, with the intention of documenting the process for myself for the future, and also publishing the process for other developers who might face a similar challenge. This process will focus on the steps and components necessary to get a self-hosted, cross-platform video working that can play reliably regardless of connection speed.

The Video Player

For a video player, I chose the open-source video.js library. I liked working with an open-source solution, rather than some of the paid solutions out there. I felt having the ability to see what was going on under the hood to be beneficial. I also liked that video.js supports a number of plugins, basically other JS libraries that extend its functionality. These included the videojs-contrib-ads and videojs-ima libraries which would allow us to run advertisements along with the video.

But the most important extension to make all of this work was the videojs-contrib-hls library, which allowed me to use the HTTP Live Streaming (HLS) protocol.

The Video Protocol

Dealing with video formats, resolutions, bitrates and frame rates was a definite challenge. My first attempt at taking the original video and dropping it on a CDN proved not to be a workable solution. While it played fine on our fast office internet connection, trying to watch the video from my phone on the train ride home later that day proved impossible. I quickly learned that using the full 1080p version of the video was a mistake. Obviously, I was going to have to re-encode the video, but at what resolution? I wanted my users with a fast connection to see the full HD version but also wanted the video to actually play for users on a flaky 4G connection.

So, I did some internet searching and started reading about the HLS protocol. This technology is developed by Apple and allows you to serve different versions of the same video files at different bit rates, depending on the connection speeds. It works by taking a video file, breaking it into small (usually 10 seconds) chunks, and then generating a playlist of all of the chunks in order. The HTML5 video element is then pointed to this playlist, and the chunks are loaded in sequence, when needed, offering a smooth playback of the video. Even better, you can repeat this process with your video at different bitrates, and HLS will choose the appropriate chunk to load, depending on the available connection speed at the time. This definitely sounded like exactly what I needed.

HLS, being an Apple technology, is natively supported on iOS and Safari. Getting it to work cross-platform required the videojs-contrib-hls library I mentioned earlier. There were also some other challenges in getting things to work across other browsers that I will elaborate on later.

Encoding the Videos

My next step was to take my full 1080p video and encode it into various bitrates for the different connection speeds. Apple has a nice guide here of the different configurations to use for different speeds.

To do the actual encoding, I used the command-line tool FFmpeg. This was another challenge. There are so many variables involved in encoding videos, that looking at the man pages, it was difficult to know even where to start. But, there were really only a handful of variables I was concerned with, which made it easier to parse all of the options. Those variables were video bitrate, audio bitrate, screen dimensions, frame rate, and keyframes. A typical command looked like this:

$ ffmpeg -i input.mp4 -c:v libx264 -c:a copy -b:v 1200k -b:a 64K -s:v 640x360 -r:v 29.97 -force_key_frames "expr:gte(t,n_forced*10)" out-ml.mp4
  • ffmpeg -i input.mp4-i indicates the source file, in this case, input.mp4
  • -c:v libx264 specifies the video codec. libx264 is the recommended codec for this application.
  • -c:a copy specifies the codec for the audio. Here we want to copy the same audio codec as the source.
  • -b:v 1200k. Encode the video at 1200kb/s.
  • -b:a 64k. Encode the audio at 64kb/s.
  • -s:v 640x360 specifies the screen size of the output, in this case, 640 pixels wide by 360 pixels tall.
  • -r:v 29.97 indicates the encoded video should have a frame rate of 29.97 fps.
  • -force_key_frames "expr:gte(t,n_forced*10)". This rather complicated command specifies that a keyframe should be created every 10 seconds. This was necessary because I found that there were hitches in the playback of the video on non-Apple browsers. After much research and experimentation, I discovered it was because the automatically-generated keyframes must line up with the size of the segments (in this case, 10 seconds).
  • out-ml.mp4 is the name of the output file. The ml, in this case, stands for ‘medium-low’. I ran the FFmpeg command several times, with the parameters suggested by Apple. Each time, I changed the suffix at the end of the output name to represent different connection speeds. When I was finished with this process, my output folder looked like this.
    ./
    ├── out-h.mp4
    ├── out-l.mp4
    ├── out-m.mp4
    ├── out-mh.mp4
    ├── out-ml.mp4
    ├── out-xh.mp4
    ├── out-xl.mp4
    └── out-xxl.mp4
    

Splitting the Videos

The next step in the process was to split the videos into 10-second segments and create a .m3u8 playlist to queue up each video in sequence. Luckily, Apple has created a tool to make this easy, the mediafilesegmenter tool. To split the videos, I ran this command:

$ mediafilesegmenter -I -start-segments-with-iframe -f ./xxl ./out-xxl.mp4

I ran this command for each of the different variants I created in the last step, and now have a folder structure like this…

. . .
├── h
│   ├── out-h.plist
│   ├── fileSequence0.ts
│   ├── fileSequence1.ts
│   ├── fileSequence10.ts
│   ├── fileSequence11.ts
│   ├── . . . 
│   ├── iframe_index.m3u8
│   └── prog_index.m3u8
├── l
│   ├── out-l.plist
│   ├── fileSequence0.ts
│   ├── fileSequence1.ts
│   ├── fileSequence10.ts
│   ├── fileSequence11.ts
│   ├── . . .
│   ├── iframe_index.m3u8
│   └── prog_index.m3u8
├── . . .

I now have the structure I need to make my variant playlist.

Creating the Variant Playlist

Apple offers another tool to assist in this process, the variantplaylistcreator. This command will create a master playlist file that includes all of the separate playlists and segments we included in the last file. This is the master playlist that is downloaded by the page and initiates the playback. The command works like this (note that I left out many of the variants, indicated by the ellipsis).

$ variantplaylistcreator -o variants.m3u8 \
./l/prog_index.m3u8 ./l/out-l.plist -iframe-url ./l/iframe_index.m3u8 \
./xl/prog_index.m3u8 ./xl/out-xl.plist -iframe-url ./xl/iframe_index.m3u8 \
. . .

HTML and JS Files

The HTML5 element on my page looks like this:

<video id="hls-video" class="video-js vjs-16-9" controls preload="auto" width="640" height="392"
        poster="video-poster.png">
  <source src="http://my.cdn.url/variants.m3u8" type="application/x-mpegURL">
  <source src="http://my.cdn.url/fallback.mp4" type="video/mp4">
  <p class="vjs-no-js">
    To view this video please enable JavaScript, and consider upgrading to a web browser that
    <a href="http://videojs.com/html5-video-support/" target="_blank">supports HTML5 video</a>
  </p>
</video>

Along with all of the video-js JS files, I uploaded player.js which instantiates and configures everything, and is documented below:

// Set some variables to make life easier later
var videoId = 'hls-video';
var player = videojs(videoId, {nativeControlsForTouch: false});

// Remove controls from the player on iPad to stop native controls from stealing
// our click
  var contentPlayer =  document.getElementById(videoId + '_html5_api');
  if ((navigator.userAgent.match(/iPad/i) ||
    navigator.userAgent.match(/Android/i)) &&
    contentPlayer.hasAttribute('controls')) {
    contentPlayer.removeAttribute('controls');
  }

// Initialize the ad container when the video player is clicked, but only the
// first time it's clicked.
  var startEvent = 'click';
  var mobile = false;
  if (navigator.userAgent.match(/iPhone/i) ||
    navigator.userAgent.match(/iPad/i) ||
    navigator.userAgent.match(/Android/i)) {
    mobile = true;
    startEvent = 'touchstart';
  }

  // Trigger automatic playback if not on mobile.
  if (!mobile) {
    player.trigger(startEvent);
  }

Deploying

Next, I uploaded my HTML and JS files to my web server, and all of the files/folders generated in the process of creating the segments and playlists to a CDN.

After deploying, my video played beautifully on Safari/Safari Mobile but was erroring on all other browsers. The reason for this was that in non-Apple browsers, the videojs-contrib-hls handles all of the loading of segments through XHR requests. Since my CDN is a different source, the AJAX requests for the video segments were being blocked due to the same origin policy. This was a quick fix, just requiring that I added Access-Control-Allow-Origin: http://mydomain.com to the response header on my CDN.

After fixing that, I finally had everything working in all browsers/platforms, and at any connection speed.

Conclusion

This whole process was frustrating at times as well as a powerful learning experience. This was a case where I had to discover the correct path by turning the wrong way often. In the end, I had a working solution that I am happy with, and I feel confident that I could implement this again in the future.

Still part of me wonders if this is the best approach? I am by no means an expert in this area, and I am sure there is room for improvement. If something about this process doesn’t look right or could be improved, please let me know in the comments or on Twitter. I’d definitely appreciate feedback in this area.

Featured image credit IntelFreePress, CC BY 2.0

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedIn

Leave a Reply

Your email address will not be published. Required fields are marked *