Adaptive HLS Streaming with Nginx

This is a follow up to my Simple(ish) Self-Hosted Streaming with Nginx guide. If you haven't read that, read it first, this will make a lot more sense.

That basic guide illustrates how to make your own 'streaming platform' independant of services like YouTube, Facebook Live, Periscope etc and their branding, advertising, restrictions, and policies.

One feature 'missing' from that simple guide is adaptive HLS, which allows bandwidth to be optimised (smaller displays and devices will request a lower resolution stream), and also ensures that the stream degrades gracefully for users with limited bandwidth (e.g. poor mobile data, rural broadband etc) which affected.

How it works

The underlying principle is really simple, the stream is converted - or transcoded - into a few different versions alongside the original HD one, each being smaller and requiring less bandwidth.

If your server is low spec, and you've got plenty of bandwidth where you're streaming from, it can be more efficient to send different streams from the source. However, this method is obviously a lot simpler for whoever's streaming content!  

With HLS, the stream is split into 6 second clips. When the browser detects that it takes longer than 6s to get the next clip, or when the window is smaller than the resolution of the clip, it will request a smaller version of the next clip.

This happens almost seamlessly, in most cases the user will see the quality change but the stream shouldn't stall or 'buffer' whilst they're watching it.

Nginx: Yes, it can do this too

We can do all of the above with nginx (seriously, this server never ceases to amaze me) and the free ffmpeg tool.

The configuration examples below assume you've got an nginx instance configured as directed in my Simple(ish) Self-Hosted Streaming guide.

Configuration

You need to make a couple of changes to the nginx.conf file, edit the livestream block as below;

application livestream {
   live on;
   exec /usr/bin/ffmpeg -i rtmp://localhost/livestream/$name -async 1 -vsync -1
     -c copy -f flv rtmp://localhost/adapthls/$name_src;
     -c:v libx264 -c:a aac -b:v 256k -b:a 32k -vf "scale=480:trunc(ow/a/2)*2" -tune zerolatency -preset veryfast -crf 23 -f flv rtmp://localhost/adapthls/$name_low
     -c:v libx264 -c:a aac -b:v 768k -b:a 96k -vf "scale=720:trunc(ow/a/2)*2" -tune zerolatency -preset veryfast -crf 23 -f flv rtmp://localhost/adapthls/$name_mid
     -c:v libx264 -c:a aac -b:v 1920k -b:a 128k -vf "scale=1280:trunc(ow/a/2)*2" -tune zerolatency -preset veryfast -crf 23 -f flv rtmp://localhost/adapthls/$name_hd; 
 }

This executes the ffmpeg command when it starts receiving a stream and, instead of converting it to HLS as before, it creates four new streams that are looped back to the server itself, each with a different qualtiy level.

Then you need to parse all four of these streams into a single HLS stream, this can be done with a new RTSP application block as follows (just insert it below the livesream block)

application adapthls {
   live on;
   hls on;
   allow publish 127.0.0.1;
   deny publish all;
   hls_path /mnt/hls;
   hls_fragment 6s;
   hls_playlist_length 60;
   hls_variant _low BANDWIDTH=288000;
   hls_variant _mid BANDWIDTH=864000;
   hls_variant _hd BANDWIDTH=2048000;
   hls_variant _src BANDWIDTH=3000000;
}

The clever bit here are the hls_variant lines which match the suffixes in the four streams above and the BANDWIDTH parameter specifies the minimum amount of bandwidth required for this stream.

Nginx handles generating the various .m3u8 playlist files needed to make this technique work transparently!

Player

You should also change a few parameters in the player HTML file to streamline how it will handle the change between different levels;  

<script>
   var player = videojs('video-player', {
   nativeAudioTracks: false,
   nativeVideoTracks: false,
   liveui: true,
   vhs: { overrideNative: true, 
          smoothQualityChange: true, 
         enableLowInitialPlaylist: true }   
   });
   player.play();
</script>

Again, you can find a lot more information on these parameters on the video.js site.

Go Live, and test!

That's it. Fire up OBS or your favourite streaming app as before and send a stream (ideally 1080p to see the full effect).

Firefox has a handy Throttling tool, as does Google Chrome. These tools allow you to simulate a slower bandwidth connection than your own, if you adjust between various connection bandwidths you should (after a few seconds) see the stream quality change.

Performance and Scalability

This is pushing it a bit for the $10/mo Digital Ocean VM I previously recommended for testing. It will just about handle the transcoding but will struggle to support many viewers. A four-core VM, however, can comfortably handle over 100 viewers in my testing, whilst also transcoding the video.