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:

video-preview-ffmpeg

You can try a live demo here.

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 ffmpegbash 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):

  1.  youtube-dl -F https://www.youtube.com/watch?v=v1uyQZNg2vE

There’s quiet a lot of them (see all supported video formats on YouTube):

  1.  [youtube] Setting language
  2.  [youtube] v1uyQZNg2vE: Downloading webpage
  3.  [youtube] v1uyQZNg2vE: Downloading video info webpage
  4.  [youtube] v1uyQZNg2vE: Extracting video information
  5.  [info] Available formats for v1uyQZNg2vE:
  6.  format code extension resolution  note
  7.  171         webm      audio only  DASH audio , audio@ 48k (worst)
  8.  140         m4a       audio only  DASH audio , [email protected]
  9.  160         mp4       144p        DASH video , video only
  10.  242         webm      240p        DASH video , video only
  11.  133         mp4       240p        DASH video , video only
  12.  243         webm      360p        DASH video , video only
  13.  134         mp4       360p        DASH video , video only
  14.  ...
  15.  22          mp4       1280x720    (best)

At the bottom there’s the default resolution of 1280x720 (best) (with format itag 22), but 360pwith itag 134 is sufficient for our needs.

It’s always better to choose those with relative width (that’s 144p240p360p480p, 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).

  1. 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.

  1. $ ffprobe -show_streams "video.mp4" 2> /dev/null | grep nb_frames | head -n1 | sed 's/.*=//'
  2. 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/nullwithout 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.

  1. $ 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/.*= *//'
  2. 4061
  • -nostats By default, ffmpeg prints progress information, but that would be immediately caught by grep because it would contain the word frame and therefore the output of this entire command would be totally random. -nostats forces ffmpeg to print just the final result.
  • -i "$MOVIE" Input file.
  • -vcodec copy -f rawvideo We don’t want to do any reformating. Force ffmpeg 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 like frame= 42 or frame=325. We can’t just use awk to print the first column because of that extra space, so we have to cut everything from the beginning of the line to the term fps (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 Nth frame.

  1. $ echo "4061 / 100" | bc
  2. 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.

  1. 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 Tell ffmpeg 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 every 40 frames see the documentation.
    • scale=-1:120 Resize frames to fit 120px 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:

  1. $ identify video_preview.jpg
  2. 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.

  1. #!/bin/bash
  2.  
  3. if [ -z "$1" ]; then
  4.     echo "usage: ./movie_preview.sh VIDEO [HEIGHT=120] [COLS=100] [ROWS=1] [OUTPUT]"
  5.     exit
  6. fi
  7.  
  8. MOVIE=$1
  9. HEIGHT=$2
  10. COLS=$3
  11. ROWS=$4
  12. OUT_FILENAME=$5
  13.  
  14. # get video name without the path and extension
  15. MOVIE_NAME=`basename $MOVIE`
  16. OUT_DIR=`pwd`
  17.  
  18. if [ -z "$HEIGHT" ]; then
  19.     HEIGHT=120
  20. fi
  21. if [ -z "$COLS" ]; then
  22.     COLS=100
  23. fi
  24. if [ -z "$ROWS" ]; then
  25.     ROWS=1
  26. fi
  27. if [ -z "$OUT_FILENAME" ]; then
  28.     OUT_FILENAME=`echo ${MOVIE_NAME%.*}_preview.jpg`
  29. fi
  30.  
  31. OUT_FILEPATH=`echo $OUT_DIR/$OUT_FILENAME`
  32.  
  33. TOTAL_IMAGES=`echo "$COLS*$ROWS" | bc`
  34.  
  35. # get total number of frames in the video
  36. # ffprobe is fast but not 100% reliable. It might not detect number of frames correctly!
  37. NB_FRAMES=`ffprobe -show_streams "$MOVIE" 2> /dev/null | grep nb_frames | head -n1 | sed 's/.*=//'`
  38.  
  39. if [ "$NB_FRAMES" = "N/A" ]; then
  40.     # as a fallback we'll use ffmpeg. This command basically copies this
  41.     # video to /dev/null and it counts frames in the process.
  42.     # It's slower (few seconds usually) than ffprobe but works everytime.
  43.     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/.*= *//'`
  44. fi
  45.  
  46. # calculate offset between two screenshots, drop the floating point part
  47. NTH_FRAME=`echo "$NB_FRAMES/$TOTAL_IMAGES" | bc`
  48. echo "capture every ${NTH_FRAME}th frame out of $NB_FRAMES frames"
  49.  
  50. # make sure output dir exists
  51. mkdir -p $OUT_DIR
  52.  
  53. 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\""
  54.  
  55. eval $FFMPEG_CMD
  56. 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.

  1. $ ./movie_preview.sh video.mp4 40 8 4

video_preview

You can also add padding and margin between images with:

  1. tile=${COLS}x${ROWS}:padding=2:margin=6

video-cols-rows

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.

You can try live demo here.

  1. <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>
  2.  
  3. <style>
  4. body {
  5.     text-align: center;
  6.     padding-top: 20px;
  7. }
  8.  
  9. .video-preview {
  10.     display: inline-block;
  11.     position: relative;
  12.     background: #ddd;
  13.     overflow: hidden;
  14.     /* This is temporary width and height, these'll be overriden when the source img is loaded.*/
  15.     /* If you already know size of a preview frame you can hardcode it here. */
  16.     width: 160px;
  17.     height: 120px;
  18.     border-radius: 3px;
  19.     box-shadow: 0 0 6px #bbb;
  20. }
  21. </style>
  22.  
  23. <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
  24. <script>
  25. (function($) {
  26.     $.fn.videoPreview = function(options) {
  27.         return this.each(function() {
  28.             var elm = $(this);
  29.             var frames = parseFloat(elm.data('frames'));
  30.  
  31.             var img = $('<img/>', { 'src': elm.data('source') }).hide().css({
  32.                 'position': 'absolute', 'cursor': 'pointer'
  33.             }).appendTo(elm);
  34.             var slider = $('<div/>').hide().css({
  35.                 'width': '2px', 'height': '100%', 'background': '#ddd', 'position': 'absolute',
  36.                 'z-index': '1', 'top': '0', 'opacity': 0.6, 'cursor': 'pointer'
  37.             }).appendTo(elm);
  38.  
  39.             var width;
  40.  
  41.             function defaultPos() {
  42.                 img.css('left', -width * frames / 4);
  43.             }
  44.  
  45.             img.load(function() { // we need to know video's full width
  46.                 $(this).show();
  47.                 width = this.width / frames;
  48.                 elm.css('width', width);
  49.                 defaultPos();
  50.             });
  51.             elm.mousemove(function(e) {
  52.                 var left = e.clientX - elm.position().left; // position inside the wrapper
  53.                 slider.show().css('left', left - 1); // -1 because it's 2px width
  54.                 img.css('left', -Math.floor((left / width) * frames) * width);
  55.             }).mouseout(function(e) {
  56.                 slider.hide();
  57.                 defaultPos();
  58.             });
  59.  
  60.         });
  61.     };
  62. })(jQuery);
  63.  
  64. $('.video-preview').videoPreview();
  65. </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