How to Generate Better Video Previews with ffmpeg
Embedding videos from third party providers like YouTube or Vimeo is very common nowadays. For whatever reason none of them give any useful previews. Youtube, for example, only generates four images to choose from, but that’s not what we want.
What would be really nice is if every video on YouTube had a preview like this:
Basically, this is just a single JPEG 21300x120
in size containing 100 small screenshots (like a film strip). See for yourself.
Fortunately for us, we can make this kind of preview with a little bit of ffmpeg
, bash
scripting and jQuery
.
For the purpose of this tutorial, I’m going to use the Google Glass teaser on YouTube and generate a preview with 100 frames that’s 120px in height. Of course, various videos have various aspect ratios but I assume that you want to have all previews with the same height and varying width.
Please note, that I’m using ffmpeg 2.2.2
, although all functionality used in this tutorial should be already implemented in older versions.
1. Download a video from YouTube, Vimeo, etc. (optional)
In order to generate a video preview we have to download the video first. I strongly recommend you use youtube-dl
(see installation notes. It’s up to date and it supports many sites). The sweet thing is that you don’t have to download full size video in order to make a preview that’s only 120px tall. Let’s list all formats that YouTube generated for this video (parameter -F
works only with YouTube):
youtube-dl -F https://www.youtube.com/watch?v=v1uyQZNg2vE
There’s quiet a lot of them (see all supported video formats on YouTube):
[youtube] Setting language
[youtube] v1uyQZNg2vE: Downloading webpage
[youtube] v1uyQZNg2vE: Downloading video info webpage
[youtube] v1uyQZNg2vE: Extracting video information
[info] Available formats for v1uyQZNg2vE:
format code extension resolution note
171 webm audio only DASH audio , audio@ 48k (worst)
140 m4a audio only DASH audio , [email protected]
160 mp4 144p DASH video , video only
242 webm 240p DASH video , video only
133 mp4 240p DASH video , video only
243 webm 360p DASH video , video only
134 mp4 360p DASH video , video only
...
22 mp4 1280x720 (best)
At the bottom there’s the default resolution of 1280x720 (best)
(with format itag 22
), but 360p
with itag 134
is sufficient for our needs.
It’s always better to choose those with relative width (that’s 144p
, 240p
, 360p
, 480p
, etc.) because fixed size formats might have black stripes at the top and bottom, and as I said earlier we want a fixed height of 120px with various widths.
We can download our video with 360p
and save it as video.mp4
(cca 8.28MB
).
youtube-dl -f 134 -o video.mp4 https://www.youtube.com/watch?v=v1uyQZNg2vE
2. Getting the total number of frames in a video
Since ffmpeg
doesn’t have any built-in option for making video previews we have to tell it to “make a screenshot every Nth frame”. In order to do this we need to know the number of frames in our video and that requires some scripting.
The easiest way is to use ffprobe
which is a utility that comes with ffmpeg
and is designed to extract various bits of information from videos in human and machine-readable formats.
$ ffprobe -show_streams "video.mp4" 2> /dev/null | grep nb_frames | head -n1 | sed 's/.*=//'
N/A
-show-streams
Shows all streams found in the video. Each video usually has two streams (video and audio).head -n1
We only care about the video stream, which comes first.sed 's/.*=//'
Grab everything after=
.
Oh, did it really return N/A
?
Unfortunately, this method works only with some videos. ffprobe
reads information from video streams and, in my experience, videos downloaded from YouTube usually have nb_frames
equal to N/A
, which is useless.
But there’s a clever workaround. We can use ffmpeg
and basically copy the video to /dev/null
without reconverting it and count its frames in the process. It’s slower (by a few seconds, usually, depending on size of your video) than ffprobe
but it works every time.
$ ffmpeg -nostats -i "video.mp4" -vcodec copy -f rawvideo -y /dev/null 2>&1 | grep frame | awk '{split($0,a,"fps")}END{print a[1]}' | sed 's/.*= *//'
4061
-nostats
By default,ffmpeg
prints progress information, but that would be immediately caught bygrep
because it would contain the wordframe
and therefore the output of this entire command would be totally random.-nostats
forcesffmpeg
to print just the final result.-i "$MOVIE"
Input file.-vcodec copy -f rawvideo
We don’t want to do any reformating. Forceffmpeg
to read and write the video as is.-y /dev/null
Dump read video data. We just want it to count frames, we don’t care about the data.awk ...
The line that’s interesting for us might look likeframe= 42
orframe=325
. We can’t just useawk
to print the first column because of that extra space, so we have to cut everything from the beginning of the line to the termfps
(eg.frame= 152
).sed ...
Grab everything after=
and ignore any spaces.
Alright, the [Google Glass teaser video] that I’m using in this tutorial should return 4061
. That’s the total number of frames in this video. If you’re having problems with this crazy one-liner try removing pipes from the right and see what it returns (first remove sed
part, then awk
and so on).
As I mentioned earlier, we want 100 frames and we’re going to use bash
to calculate that we want to capture every N
th frame.
$ echo "4061 / 100" | bc
40
This should return 40
, which means that we will capture every 40th frame (including frame number 0
). bc
is a simple calculator that should come with all Linux distributions (including OS X).
3. Generating JPEG
preview from a video
Generating a preview is just another one-liner where ffmpeg
captures images and joins them into a single long film strip.
ffmpeg -loglevel panic -y -i "video.mp4" -frames 1 -q:v 1 -vf "select=not(mod(n\,40)),scale=-1:120,tile=100x1" video_preview.jpg
-loglevel panic
We don’t want to see any output. You can remove this option if you’re having any problems seeing what went wrong.-i "$MOVIE"
The input file.-y
Override any existing output file.-frames 1
Tellffmpeg
that output from this command is just a single image (one frame).-q:v 1
Output quality,0
is the best.-vf select=
That’s where all the magic happens. This is the selector function for video filter.not(mod(n\,40))
Select one frame every40
frames see the documentation.scale=-1:120
Resize frames to fit120px
height, and the width is adjusted automatically to keep the correct aspect ratio.tile=100x1
Layout captured frames into this grid.
We can check what size is video_preview.jpg
with identify
from ImageMagick
:
$ identify video_preview.jpg
video_preview.jpg JPEG 21300x120 21300x120+0+0 8-bit sRGB 735KB 0.000u 0:00.000
And that’s it. Running all these commands by themselves is a bit clumsy, so we can put them all into a one bash script with variables and command line arguments.
#!/bin/bash
if [ -z "$1" ]; then
echo "usage: ./movie_preview.sh VIDEO [HEIGHT=120] [COLS=100] [ROWS=1] [OUTPUT]"
exit
fi
MOVIE=$1
HEIGHT=$2
COLS=$3
ROWS=$4
OUT_FILENAME=$5
# get video name without the path and extension
MOVIE_NAME=`basename $MOVIE`
OUT_DIR=`pwd`
if [ -z "$HEIGHT" ]; then
HEIGHT=120
fi
if [ -z "$COLS" ]; then
COLS=100
fi
if [ -z "$ROWS" ]; then
ROWS=1
fi
if [ -z "$OUT_FILENAME" ]; then
OUT_FILENAME=`echo ${MOVIE_NAME%.*}_preview.jpg`
fi
OUT_FILEPATH=`echo $OUT_DIR/$OUT_FILENAME`
TOTAL_IMAGES=`echo "$COLS*$ROWS" | bc`
# get total number of frames in the video
# ffprobe is fast but not 100% reliable. It might not detect number of frames correctly!
NB_FRAMES=`ffprobe -show_streams "$MOVIE" 2> /dev/null | grep nb_frames | head -n1 | sed 's/.*=//'`
if [ "$NB_FRAMES" = "N/A" ]; then
# as a fallback we'll use ffmpeg. This command basically copies this
# video to /dev/null and it counts frames in the process.
# It's slower (few seconds usually) than ffprobe but works everytime.
NB_FRAMES=`ffmpeg -nostats -i "$MOVIE" -vcodec copy -f rawvideo -y /dev/null 2>&1 | grep frame | awk '{split($0,a,"fps")}END{print a[1]}' | sed 's/.*= *//'`
fi
# calculate offset between two screenshots, drop the floating point part
NTH_FRAME=`echo "$NB_FRAMES/$TOTAL_IMAGES" | bc`
echo "capture every ${NTH_FRAME}th frame out of $NB_FRAMES frames"
# make sure output dir exists
mkdir -p $OUT_DIR
FFMPEG_CMD="ffmpeg -loglevel panic -i \"$MOVIE\" -y -frames 1 -q:v 1 -vf \"select=not(mod(n\,$NTH_FRAME)),scale=-1:${HEIGHT},tile=${COLS}x${ROWS}\" \"$OUT_FILEPATH\""
eval $FFMPEG_CMD
echo $OUT_FILEPATH
More documented code is also available on gist.github.com.
This script takes up to four arguments. Frame height, number of columns and rows and output file. By default, it generates the same results as we did in this tutorial but you can use it to generate previews like this if you wish.
$ ./movie_preview.sh video.mp4 40 8 4
You can also add padding and margin between images with:
tile=${COLS}x${ROWS}:padding=2:margin=6
4. Embedding video previews on the web
I assume we want to use this preview on a website, so we need to wrap it with some HTML, CSS and jQuery. The default frame is set to 1/4 of the video, and when you move your mouse over the preview it moves the background with our film strip.
<a href="https://www.youtube.com/watch?v=v1uyQZNg2vE" target="_blank" class="video-preview" data-frames="100" data-source="http://i.imgur.com/BX0pV4J.jpg"></a>
<style>
body {
text-align: center;
padding-top: 20px;
}
.video-preview {
display: inline-block;
position: relative;
background: #ddd;
overflow: hidden;
/* This is temporary width and height, these'll be overriden when the source img is loaded.*/
/* If you already know size of a preview frame you can hardcode it here. */
width: 160px;
height: 120px;
border-radius: 3px;
box-shadow: 0 0 6px #bbb;
}
</style>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script>
(function($) {
$.fn.videoPreview = function(options) {
return this.each(function() {
var elm = $(this);
var frames = parseFloat(elm.data('frames'));
var img = $('<img/>', { 'src': elm.data('source') }).hide().css({
'position': 'absolute', 'cursor': 'pointer'
}).appendTo(elm);
var slider = $('<div/>').hide().css({
'width': '2px', 'height': '100%', 'background': '#ddd', 'position': 'absolute',
'z-index': '1', 'top': '0', 'opacity': 0.6, 'cursor': 'pointer'
}).appendTo(elm);
var width;
function defaultPos() {
img.css('left', -width * frames / 4);
}
img.load(function() { // we need to know video's full width
$(this).show();
width = this.width / frames;
elm.css('width', width);
defaultPos();
});
elm.mousemove(function(e) {
var left = e.clientX - elm.position().left; // position inside the wrapper
slider.show().css('left', left - 1); // -1 because it's 2px width
img.css('left', -Math.floor((left / width) * frames) * width);
}).mouseout(function(e) {
slider.hide();
defaultPos();
});
});
};
})(jQuery);
$('.video-preview').videoPreview();
</script>
What about GIF animations?
You can generate GIF animations directly from ffmpeg,
but it usually doesn’t give very good results because of some predefined color palette used by ffmpeg
.
Therefore, it requires a two step process where you first output all video frames into a .png
file and then create animation using convert
from the ImageMagick
suite. In fact, that’s how I made the animation at the beginning of this tutorial.
You can read more and see examples here.
Also note, that GIF animation with the same number of frames like our JPEG film strip will be significantly larger (a few MBs).
I hope this tutorial helped you generate better previews! If you have any questions or something you’d like to mention, drop me a line in the comments!
Author: Martin Sikora