10/1/2017 - 12:06 PM

Source code for Binpress tutorial:

# Bash script that generates film strip video preview using ffmpeg
# You can see live demo:
# Tutorial on

if [ -z "$1" ]; then
    echo "usage: ./ VIDEO [HEIGHT=120] [COLS=100] [ROWS=1] [OUTPUT]"

# get video name without the path and extension
MOVIE_NAME=`basename $MOVIE`


if [ -z "$HEIGHT" ]; then
if [ -z "$COLS" ]; then
if [ -z "$ROWS" ]; then
if [ -z "$OUT_FILENAME" ]; then
    OUT_FILENAME=`echo ${MOVIE_NAME%.*}_preview.jpg`


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/.*=//'`
# `-show-streams` Show all streams found in the video. Each video has usualy two streams (video and audio).
# `head -n1` We care only about the video stream which comes first.
# `sed 's/.*=//'` Grab everything after `=`.

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/.*= *//'`
    # I know, that `awk` and `sed` parts look crazy but it has to be like this because ffmpeg can
    # `-nostats` By default, `ffmpeg` prints progress information but that would be immediately caught by `grep`
    #     because it would contain word `frame` and therefore 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 we're interested in has format might look like `frame= 42` or `frame=325`. Because of that
    #     extra space we can't just use `awk` to print the first column and we have to cut everything from the
    #     beggining of the line to the term `fps` (eg. `frame= 152`).
    # `sed ...` Grab everything after `=` and ignore any spaces

# calculate offset between two screenshots, drop the floating point part
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\""
# `-loglevel panic` We don’t want to see any output. You can remove this option if you’re having any problem to see what went wrong
# `-i "$MOVIE"` 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 3` Output quality where `0` is the best.
# `-vf \"select=` That's where all the magic happens. Selector function for [video filter](
# # `not(mod(n\,58))` Select one frame every `58` frames [see the documentation](
# # `scale=-1:120` Resize to fit `120px` height, width is adjusted automatically to keep correct aspect ration.
# # `tile=${COLS}x${ROWS}` Layout captured frames into this grid

# print enire command for debugging purposes
# echo $FFMPEG_CMD


Tutorial code for:
<!DOCTYPE html>
<html lang="en">
    <a href="" target="_blank" class="video-preview" data-frames="100" data-source=""></a>

    body {
        text-align: center;
        padding-top: 20px;
    body > div {/*
        display: inline-block;*/

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

    <script src=""></script>
    (function($) {
        $.fn.videoPreview = function(options) {
            return this.each(function() {
                var elm = $(this);
                var frames = parseFloat('frames'));

                var img = $('<img/>', { 'src':'source') }).hide().css({
                    'position': 'absolute',
                    'cursor': 'pointer'
                var slider = $('<div/>').hide().css({
                    'width': '2px',
                    'height': '100%',
                    'background': '#ddd',
                    'position': 'absolute',
                    'z-index': '1',
                    'top': '0',
                    'opacity': 0.6,
                    'cursor': 'pointer'

                var width;

                function defaultPos() {
                    img.css('left', -width * frames / 4);

                img.load(function() {
                    width = this.width / frames;
                    elm.css('width', width);
                elm.mousemove(function(e) {
                    var left = e.clientX - elm.position().left;
          'left', left - 1); // -1 because it's 2px width
                    img.css('left', -Math.floor((left / width) * frames) * width);
                }).mouseout(function(e) {