I had a simple dream. A dream of embedding videos in a web page without using third party services such as youtube or vimeo. For a while it seemed that I found the solution: using ffmpeg to convert the source videos to highly optimized h264 mp4 files. I wrote about my process before. The drawback is, that you only get one quality setting. Use a high bitrate and the video will load slowly or stutter on slow connections. Use a low bitrate and it will load smoothly but never looked quite as beautiful as it should. You can never win.
Luckily, there is a solution: Dynamic Adaptive Streaming over HTTP (MPEG-DASH). The concept is quite simple: you generate a few versions of your video with different bitrates, a manifest file that links to those versions, and let the browser decide - according to the current bandwidth - which version to load.
But see for yourself:
A single page with three autoplaying videos, using the standard html5 video.
And the same page, but with MPEG DASH.
Every modern browser supports MPEG-DASH through the use of media source extensions. Well, except iOS Safari and Opera Mini. For those, an additional HLS stream - or a fallback to a normal mp4 file - can be used. Media source extension support in the browsers means that you still have to use an additional js player such as dash.js or shaka player to play your MPEG DASH streams.
So, lets convert our videos with some command line magic. You will need ffmpeg for the conversion and MP4box(part of gpac) to create the manifest file. On a mac, the easiest way to get those is through Homebrew.
brew install ffmpeg gpac
Convert the source file called input.mov into an audio file and two video files:
ffmpeg -y -i "input.mov" -c:a aac -b:a 192k -vn "output_audio.m4a"
ffmpeg -y -i "input.mov" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 22 -maxrate 5000k -bufsize 10000k -pix_fmt yuv420p -f mp4 "output_5000.mp4"
ffmpeg -y -i "input.mov" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 23 -maxrate 2000k -bufsize 4000k -pix_fmt yuv420p -f mp4 "output_2000.mp4"
What do all those options mean?
Well, first an m4a file for audio is created, with the audio stream encoded in aac with a bitrate of 192 kb. Then, an mp4 file for video is created, with a slow preset (how fast it converts), with the encoder tuned to a film source medium (use -tune animation for animation or -tune grain to preserve grain), with -vsync passthrough to prevent ffmpeg messing with the frames, without the timecode track that may be found in files exported from final cut, without audio, with a x264 codec with every 25th frame an I-Frame, with a constant quality rate of 22 but not more than 5000 kbps, using a buffer size of 10000 kb, with a pixel format of yuv into an mp4 file output_5000.mp4. Then we’ll do the same for the second file but with a lower maxrate. Read more about the options in the Encoding guide.
Now that the files are created, we can make a manifest file:
MP4Box -dash 2000 -rap -frag-rap -bs-switching no -profile "dashavc264:live" "output_5000.mp4" "output_3000.mp4" "output_audio.m4a" -out "output/output.mpd"
This creates a folder ‘output’ with the manifest file, and converts the mp4 files to streams in the same directory. Use -profile onDemand if you don’t want your file broken up in 2000 millisecond parts. Though the live profile is the suggested solution.
Now put the video in your html file, along with a link to mp4 as a fallback:
<video data-dashjs-player loop="true" autoplay="true" >
<source src="/output/output.mpd" type="application/dash+xml">
<source src="/walking.mp4" type="video/mp4">
</video>
Don’t forget to include the dash.js player near the end of your html file:
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
Reload the page. It should play now! Use your browsers network tools to see which version is loaded.
Of course that is quite a lot of work to do it all manually for every video file you have. So just use a bash script instead!
#!/bin/bash
# THIS SCRIPT CONVERTS EVERY MP4 (IN THE CURRENT FOLDER AND SUBFOLDER) TO A MULTI-BITRATE VIDEO IN MP4-DASH
# For each file "videoname.mp4" it creates a folder "dash_videoname" containing a dash manifest file "stream.mpd" and subfolders containing video segments.
# Explanation:
# https://rybakov.com/blog/
# Validation tool:
# http://dashif.org/conformance.html
# MDN reference:
# https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery/Setting_up_adaptive_streaming_media_sources
# Add the following mime-types (uncommented) to .htaccess:
# AddType video/mp4 m4s
# AddType application/dash+xml mpd
# Use type="application/dash+xml"
# in html when using mp4 as fallback:
# <video data-dashjs-player loop="true" >
# <source src="/walking/walking.mpd" type="application/dash+xml">
# <source src="/walking/walking.mp4" type="video/mp4">
# </video>
# DASH.js
# https://github.com/Dash-Industry-Forum/dash.js
MYDIR=$(dirname $(readlink -f ${BASH_SOURCE[0]}))
SAVEDIR=$(pwd)
# Check programs
if [ -z "$(which ffmpeg)" ]; then
echo "Error: ffmpeg is not installed"
exit 1
fi
if [ -z "$(which MP4Box)" ]; then
echo "Error: MP4Box is not installed"
exit 1
fi
cd "$MYDIR"
TARGET_FILES=$(find ./ -maxdepth 1 -type f \( -name "*.mov" -or -name "*.mp4" \))
for f in $TARGET_FILES
do
fe=$(basename "$f") # fullname of the file
f="${fe%.*}" # name without extension
if [ ! -d "${f}" ]; then #if directory does not exist, convert
echo "Converting \"$f\" to multi-bitrate video in MPEG-DASH"
mkdir "${f}"
ffmpeg -y -i "${fe}" -c:a aac -b:a 192k -vn "${f}_audio.m4a"
ffmpeg -y -i "${fe}" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 22 -maxrate 5000k -bufsize 12000k -pix_fmt yuv420p -f mp4 "${f}_5000.mp4"
ffmpeg -y -i "${fe}" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 23 -maxrate 3000k -bufsize 6000k -pix_fmt yuv420p -f mp4 "${f}_3000.mp4"
ffmpeg -y -i "${fe}" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 23 -maxrate 1500k -bufsize 3000k -pix_fmt yuv420p -f mp4 "${f}_1500.mp4"
ffmpeg -y -i "${fe}" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 23 -maxrate 800k -bufsize 2000k -pix_fmt yuv420p -vf "scale=-2:720" -f mp4 "${f}_800.mp4"
ffmpeg -y -i "${fe}" -preset slow -tune film -vsync passthrough -an -c:v libx264 -x264opts 'keyint=25:min-keyint=25:no-scenecut' -crf 23 -maxrate 400k -bufsize 1000k -pix_fmt yuv420p -vf "scale=-2:540" -f mp4 "${f}_400.mp4"
# static file for ios and old browsers and mobile safari
ffmpeg -y -i "${fe}" -preset slow -tune film -movflags +faststart -vsync passthrough -c:a aac -b:a 160k -c:v libx264 -crf 23 -maxrate 2000k -bufsize 4000k -pix_fmt yuv420p -f mp4 "${f}/${f}.mp4"
rm -f ffmpeg*log*
# if audio stream does not exist, ignore it
if [ -e "${f}_audio.m4a" ]; then
MP4Box -dash 2000 -rap -frag-rap -bs-switching no -profile "dashavc264:live" "${f}_5000.mp4" "${f}_3000.mp4" "${f}_1500.mp4" "${f}_800.mp4" "${f}_400.mp4" "${f}_audio.m4a" -out "${f}/${f}.mpd"
rm "${f}_5000.mp4" "${f}_3000.mp4" "${f}_1500.mp4" "${f}_800.mp4" "${f}_400.mp4" "${f}_audio.m4a"
else
MP4Box -dash 2000 -rap -frag-rap -bs-switching no -profile "dashavc264:live" "${f}_5000.mp4" "${f}_3000.mp4" "${f}_1500.mp4" "${f}_800.mp4" "${f}_400.mp4" -out "${f}/${f}.mpd"
rm "${f}_5000.mp4" "${f}_3000.mp4" "${f}_1500.mp4" "${f}_800.mp4" "${f}_400.mp4"
fi
# create a jpg for poster. Use imagemagick or just save the frame directly from ffmpeg is you don't have cjpeg installed.
ffmpeg -i "${fe}" -ss 00:00:00 -vframes 1 -qscale:v 10 -n -f image2 - | cjpeg -progressive -quality 75 -outfile "${f}"/"${f}".jpg
fi
done
cd "$SAVEDIR"
This script creates, for every mov or mp4 file in the current directory:
- a directory named after the file with:
- five different quality versions,
- an audio file,
- mpd manifest file,
- a differently encoded mp4 file as a fallback
- a jpeg file to use as a poster
What else is to do? Read more about MPEG-DASH on MDN.
Apple announced support of fragmented MP4 for HLS m3u8. That means that we could use the same stream for DASH as well as HLS! Waiting for MP4box feature request to close, to generate m3u8 for HLS directly. Shaka Player v 2.3 shall support src= for mp4 files which should make mp4 fallback and HLS support easy.
Update 24.9
-dash-strict was removed, using -dash instead.
I started using the Shaka Player instead of Dash.js.
Shaka Player loads faster and has more configuration options, such as prefered bandwidth. If you are using Shaka Player with the hugo static site generator, you will just have to define a shortcode and include the Shaka Player js file in the header of your site:
<script src="https://cdnjs.cloudflare.com/ajax/libs/shaka-player/2.5.0/shaka-player.compiled.js"></script>
For my shortcode I use:
<div class="video-wrapper">
<video id ="{{ index .Params 0 }}" class="looped" loop="true" muted poster="/video/{{ index .Params 0 }}/{{ index .Params 0 }}.jpg">
<source src="/video/{{ index .Params 0 }}/{{ index .Params 0 }}.mp4" type="video/mp4">
</video>
</div>
<script>
var base_url = window.location.origin;
var video = document.getElementById('{{ index .Params 0 | safeJS}}');
window.shaka.polyfill.installAll();
if (!shaka.Player.isBrowserSupported()) {
}
else {
var player = new shaka.Player(video);
player.configure({
abr:{ //let it load a 3500 kbps version first
defaultBandwidthEstimate: 3500000
},
streaming: {
bufferingGoal: 4, //small buffers to enable quick quality switch
rebufferingGoal:2,
bufferBehind: 20
}
}); //full url for safari compatibility
var fullurl = base_url + "/video/{{ index .Params 0 | safeJS}}/{{ index .Params 0 | safeJS}}.mpd";
player.load(fullurl);
}
</script>
In case of disabled javascript or unsupported browsers the video specified in the source tag will be used. Adjust the streaming and abr configuration to your taste, following the documentation.