How To: Create adaptive HLS/MPEG-DASH videos for your static site

Posted on 21 March 2021

Why use Youtube when you can use way more effort to replicate the functionality youself? Here’s how to create an adaptive video.

Let’s create the different versions of the video first. I’m starting from prores, but anything that FFMPEG can read is fine.

ffmpeg -y -i prores.mxf ^
  -preset slower -g 48 -pix_fmt yuv420p -deadline good -pass 1 -row-mt 1 -an ^
  -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0^
  -filter:v:0 "scale=-2:480" -c:v:0 libx264 -b:v:0 1600k -profile:v:0 main^
  -filter:v:1 "scale=-2:720" -c:v:1 libx264 -b:v:1 4M -profile:v:1 main^
  -c:v:2 libx264 -b:v:2 6M -profile:v:2 main^
  -c:v:3 libx264 -b:v:3 8M -profile:v:3 high^
  -c:v:4 libvpx-vp9  -b:v:4 6M ^
  -c:v:5 libvpx-vp9 -b:v:5 8M ^
  -c:v:6 libvpx-vp9 -b:v:6 12M ^
  -c:v:7 libvpx-vp9 -b:v:7 18M ^
  -c:v:8 libx264 -b:v:8 25M -profile:v:8 high^
  -passlogfile:v:0 log0 ^
  -passlogfile:v:1 log1 ^
  -passlogfile:v:2 log2 ^
  -passlogfile:v:3 log3 ^
  -passlogfile:v:4 log4 ^
  -passlogfile:v:5 log5 ^
  -passlogfile:v:6 log6 ^
  -passlogfile:v:7 log7 ^
  -passlogfile:v:8 log8 ^
  -f null NUL
ffmpeg -y -i prores.mxf ^
  -preset slower -g 48 -pix_fmt yuv420p -deadline good -row-mt 1 -pass 2 ^
  -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:0 -map 0:a:0^
  -filter:v:0 "scale=-2:480" -c:v:0 libx264 -b:v:0 1600k -profile:v:0 main^
  -filter:v:1 "scale=-2:720" -c:v:1 libx264 -b:v:1 4M -profile:v:1 main^
  -c:v:2 libx264 -b:v:2 6M -profile:v:2 main^
  -c:v:3 libx264 -b:v:3 8M -profile:v:3 high^
  -c:v:4 libvpx-vp9 -b:v:4 6M ^
  -c:v:5 libvpx-vp9 -b:v:5 8M ^
  -c:v:6 libvpx-vp9 -b:v:6 12M ^
  -c:v:7 libvpx-vp9 -b:v:7 18M ^
  -c:v:8 libx264 -b:v:8 25M -profile:v:8 high^
  -passlogfile:v:0 log0 ^
  -passlogfile:v:1 log1 ^
  -passlogfile:v:2 log2 ^
  -passlogfile:v:3 log3 ^
  -passlogfile:v:4 log4 ^
  -passlogfile:v:5 log5 ^
  -passlogfile:v:6 log6 ^
  -passlogfile:v:7 log7 ^
  -passlogfile:v:8 log8 ^
  -s:a:0 aac ^
  -f dash ^
  -single_file 1 ^
  -hls_playlist true ^
  -streaming 0 ^
  -adaptation_sets "id=0,streams=v id=1,streams=a" ^
  out.mpd

Let’s go through what’s happening here. 9 video streams of varying bitrate are being created, 5 in h.264, and 4 in vp9. H.264 is mostly used as a fallback, because VP9 hardware decoding is pretty common these days. Also h.264 is used for ios devices with the HLS version.

The 25 mbps h264 stream is created mostly to bring the average bitrate of the h.264 streams higher than VP9 because shaka player currently defaults to the codec with the lowest average bitrate for some reason. Once shaka player allows specifying a codec order preference that won’t really be necessary.

One audio stream in AAC is created and shared between all the video bitrates. Two passes are being done because we have time. This is also formatted for Windows (if you’re using Linux/OSX just switch the ^ to \ and NUL to /dev/null). We’re doing single file mode which means the client does HTTP range requests to request part of the file instead of creating a bunch of files for each segment.

And -hls_playlist creates an hls playlist in addition to the dash playlist. -g needs to be set to 2x the framerate to for the GOP to be 2 seconds. You can tweak the available bitrates to suit your needs.

Once this is finished running you should get something similar to this:

.
└── Video Folder/
    ├── master.m3u8
    ├── media_0.m3u8
    ├── media_1.m3u8
    ├── media_3.m3u8
    ├── media_8.m3u8
    ├── media_9.m3u8
    ├── out.mpd
    ├── out-stream0.mp4
    ├── out-stream1.mp4
    ├── out-stream2.mp4
    ├── out-stream3.mp4
    ├── out-stream4.mp4
    ├── out-stream5.webm
    ├── out-stream6.webm
    ├── out-stream7.webm
    ├── out-stream8.mp4
    ├── out-stream9.mp4
    └── prores.mxf

Upload everything to your server (except the original ProRes), then on the client side you need something that looks like this:

<!DOCTYPE html>
<html>
  <head>
    <!-- Shaka Player compiled library: -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/shaka-player/3.0.10/shaka-player.compiled.js"></script>
    <!-- Your application source: -->
    <script src="myapp.js"></script>
  </head>
  <body>
    <video id="video"
           width="640"
           controls autoplay></video>
  </body>
</html>

and

// myapp.js
const manifestUri =
{
    dash: 'adaptiveDemo/out.mpd',
    hls: "adaptiveDemo/master.m3u8"
}
function initApp() {
  // Install built-in polyfills to patch browser incompatibilities.
  shaka.polyfill.installAll();

  // Check to see if the browser supports the basic APIs Shaka needs.
  if (shaka.Player.isBrowserSupported()) {
    // Everything looks good!
    initPlayer();
  } else {
    // This browser does not have the minimum set of APIs we need.
    console.error('Browser not supported!');
  }
}

async function initPlayer() {
  // Create a Player instance.
  const video = document.getElementById('video');
  const player = new shaka.Player(video);

  // Attach player to the window to make it easy to access in the JS console.
  window.player = player;

  // Listen for error events.
  player.addEventListener('error', onErrorEvent);

  // Try to load a manifest.
  // This is an asynchronous process.
  try {
    const support = await shaka.Player.probeSupport();
    if (support.manifest.mpd) {
        console.log("using dash");
        await player.load(manifestUri.dash);
    } else {
        console.log("using hls");
        await player.load(manifestUri.hls);
    }
    
    // This runs if the asynchronous load is successful.
    console.log('The video has now been loaded!');
  } catch (e) {
    // onError is executed if the asynchronous load fails.
    onError(e);
  }
}

function onErrorEvent(event) {
  // Extract the shaka.util.Error object from the event.
  onError(event.detail);
}

function onError(error) {
  // Log the error.
  console.error('Error code', error.code, 'object', error);
}

document.addEventListener('DOMContentLoaded', initApp);

Notice the how we check for browser support and use HLS if dash doesn’t work. This is for ios devices because they don’t support MPEG-DASH.

And that’s it! Here’s a demo with some ARRI stock footage:

You can see how this would be very easy to integrate into your CI/CD and/or static site generator if you choose to. I didn’t bother integrating the encoding into CI/CD but you can look at the source code of this page if you want to see how I made calling the functionality easier so I can do:

<video id="adaptiveVideoDemo" height="480p" controls></video>
<script>
    adaptiveVideos.push({
        elemId: "adaptiveVideoDemo",
        dashUrl: "/videos/adaptiveDemo/out.mpd",
        hlsUrl: "/videos/adaptiveDemo/master.m3u8"
    });
</script>

on any page of this site and get my adaptive video working.