Create Short Preview from Video

By  on  

I reference an adult video (porn) site in this post.  We're all grown ups, please do not be offended.

Displaying a preview image before loading a video is common practice with web media these days; oftentimes that image preview is the video's first frame or an important frame from the video.  If you've visited an adult site recently (ahem), you will have noticed that not only do you get a preview image but hovering over said image shows a small sample video with snippets at different points of the movie.  Naturally I was interested in how all of this worked and concluded the following:

  • They create the preview image using a tool like ffmpeg
  • Upon hover of image, the preview video is loaded asynchronously in the background
  • Once the preview image has loaded, the video is displayed over the image and autoplayed
  • When the mouse leaves, the video is hidden (in case the user hovers over the image again)

Achieving the <image> and <video> element swap is simple but I wanted to know how PornHub, the largest adult site network, generated their preview videos.  The idea behind the preview video is simple, of course, but I wanted to teach myself more about ffmpeg and bash scripting.  Here's the proof:

Video preview generated from a local download of Kung Fury.

Let's deconstruct my code to learn how this (very basic) preview video can be created!

Running the Script

Our script, preview.sh, will be called as follows:

# ./preview.sh {input.format} {output.format}
$ ./preview.sh kung-fury.mp4 kung-fury-preview.mp4

In a perfect world the script would accept loads more arguments, like desired video size or any of the settings we'll assume within the script, but I wanted to focus on getting the meat of the script working; adding command line arguments would be a routine exercise when everything else was a go.

The Script: preview.sh

The script starts with some very basic validation:

sourcefile=$1
destfile=$2

# Overly simple validation
if [ ! -e "$sourcefile" ]; then
  echo 'Please provide an existing input file.'
  exit
fi

if [ "$destfile" == "" ]; then
  echo 'Please provide an output preview file name.'
  exit
fi

Next we have a series of variables relating to video length, desired points in the video to cut previews, the video size, etc.:

# Detect destination file extension
extension=${destfile#*.}

# Get video length in seconds
length=$(ffprobe $sourcefile  -show_format 2>&1 | sed -n 's/duration=//p' | awk '{print int($0)}')

# Start 20 seconds into the video to avoid opening credits (arbitrary)
starttimeseconds=20

# Mini-snippets will be 2 seconds in length
snippetlengthinseconds=2

# We'll aim for 5 snippets spread throughout the video
desiredsnippets=5

# Ensure the video is long enough to even bother previewing
minlength=$(($snippetlengthinseconds*$desiredsnippets))

# Video dimensions (these could probably be command line arguments)
dimensions=640:-1

# Temporary directory and text file where we'll store snippets
# These will be cleaned up and removed when the preview image is generated
tempdir=snippets
listfile=list.txt

If the video is too short to generate a preview from, simply bail:

# Display and check video length
echo 'Video length: ' $length
if [ "$length" -lt "$minlength" ]
then
  echo 'Video is too short.  Exiting.'
  exit
fi

Next we generate a series of "snippet" videos at calculated times within the video, saving these snippet videos to a temporary directory:

# Loop and generate video snippets
mkdir $tempdir
interval=$(($length/$desiredsnippets-$starttimeseconds))
for i in $(seq 1 $desiredsnippets)
  do
    # Format the second marks into hh:mm:ss format
    start=$(($(($i*$interval))+$starttimeseconds))
    formattedstart=$(printf "%02d:%02d:%02d\n" $(($start/3600)) $(($start%3600/60)) $(($start%60)))
    echo 'Generating preview part ' $i $formattedstart
    # Generate the snippet at calculated time
    ffmpeg -i $sourcefile -vf scale=$dimensions -preset fast -qmin 1 -qmax 1 -ss $formattedstart -t $snippetlengthinseconds $tempdir/$i.$extension &>/dev/null
done

Note: copying and slicing videos with ffmpeg is difficult if you aren't a codec and media expert, which I certainly am not.  These basic settings do allow the job to get done but maybe not in the most optimal way.  Please comment below if you know a way to improve performance or video quality.

The last step is concatenating the snippet videos in to the final preview video:

# Concat videos
echo 'Generating final preview file'

# Generate a text file with one snippet video location per line
# (https://trac.ffmpeg.org/wiki/Concatenate)
for f in $tempdir/*; do echo "file '$f'" >> $listfile; done

# Concatenate the files based on the generated list
ffmpeg -f concat -safe 0 -i $listfile -c copy $destfile &>/dev/null

echo 'Done!  Check ' $destfile '!'

# Cleanup
rm -rf $tempdir $listfile

Video concatenation is easy in this case because we're concatenating snippets from the same original video.  Concatenation would be much more difficult if we were using videos of different frame rates, sizes, etc.

The final script in all its glory:

sourcefile=$1
destfile=$2

# Overly simple validation
if [ ! -e "$sourcefile" ]; then
  echo 'Please provide an existing input file.'
  exit
fi

if [ "$destfile" == "" ]; then
  echo 'Please provide an output preview file name.'
  exit
fi

# Detect destination file extension
extension=${destfile#*.}

# Get video length in seconds
length=$(ffprobe $sourcefile  -show_format 2>&1 | sed -n 's/duration=//p' | awk '{print int($0)}')

# Start 20 seconds into the video to avoid opening credits (arbitrary)
starttimeseconds=20

# Mini-snippets will be 2 seconds in length
snippetlengthinseconds=2

# We'll aim for 5 snippets spread throughout the video
desiredsnippets=5

# Ensure the video is long enough to even bother previewing
minlength=$(($snippetlengthinseconds*$desiredsnippets))

# Video dimensions (these could probably be command line arguments)
dimensions=640:-1

# Temporary directory and text file where we'll store snippets
# These will be cleaned up and removed when the preview image is generated
tempdir=snippets
listfile=list.txt

# Display and check video length
echo 'Video length: ' $length
if [ "$length" -lt "$minlength" ]
then
  echo 'Video is too short.  Exiting.'
  exit
fi

# Loop and generate video snippets
mkdir $tempdir
interval=$(($length/$desiredsnippets-$starttimeseconds))
for i in $(seq 1 $desiredsnippets)
  do
    # Format the second marks into hh:mm:ss format
    start=$(($(($i*$interval))+$starttimeseconds))
    formattedstart=$(printf "%02d:%02d:%02d\n" $(($start/3600)) $(($start%3600/60)) $(($start%60)))
    echo 'Generating preview part ' $i $formattedstart
    # Generate the snippet at calculated time
    ffmpeg -i $sourcefile -vf scale=$dimensions -preset fast -qmin 1 -qmax 1 -ss $formattedstart -t $snippetlengthinseconds $tempdir/$i.$extension &>/dev/null
done

# Concat videos
echo 'Generating final preview file'

# Generate a text file with one snippet video location per line
# (https://trac.ffmpeg.org/wiki/Concatenate)
for f in $tempdir/*; do echo "file '$f'" >> $listfile; done

# Concatenate the files based on the generated list
ffmpeg -f concat -safe 0 -i $listfile -c copy $destfile &>/dev/null

echo 'Done!  Check ' $destfile '!'

# Cleanup
rm -rf $tempdir $listfile

Improvements

You can bet that PornHub and every other video site that employs this technique has a script much more secure and powerful than this script.  A few ways this script could be improved:

  • Validation:  I check that the file exists but there's no check to ensure it's a valid media file.
  • Command Line Arguments:  Settings like video size should probably be an optional command line argument.
  • Encoding:  I know that additional command line arguments need to be passed in depending on video file type (and there are a billion out there).  A serious script would really beef of that logic.
  • Optimization:  I'm not even close to a video or media expert of any kind.  There has to be a way to speed up the snippet generation process as it's quite slow within the script now.  I usually add -c copy when I slice files but ffmpeg yelled at me so I passed on that argument.  Please share if you know how to improve the slicing or concatenation process!
  • Dependencies:  Not necessarily and improvement but it's important to call out that a simple brew install ffmpeg wont get you many of the codec and encoding support your need.  I needed to do the following to work with webm's, for example:
    brew reinstall ffmpeg --with-fdk-aac --with-ffplay --with-freetype --with-frei0r --with-libass --with-libvo-aacenc --with-libvorbis --with-libvpx --with-opencore-amr --with-openjpeg --with-opus --with-rtmpdump --with-speex --with-theora --with-tools --with-libvorbis
    

Taking the time to piece together this script was exhilarating; a mix of media handling, bash scripting, and seeing the end product for something that's used on scores of video sites makes for a real feeling of achievement.  Please let me know how the script above can be improved!

Recent Features

Incredible Demos

Discussion

  1. hatoro

    As always thanks for sharing your thoughts. I am interested in the details of said and element swap. Surely it involves async loading of video onhover? I would use webpack, what about you?

  2. John Brown

    “We’re all grown ups” Absolutely wrong! I’m in my early 17’s. Still visiting this site. Moreover if we grown up so it means we seen porn or gave reference from those nasty sites? ridiculus logic.

    • Tom

      He was referring to personal mindset not physical age. Get over it.

  3. Awesome! Thanks for the post + script. :) I’m interested in writing a script to pull a poster image out of a webm or mp4 file in order to automate the process a little. Just started scripting with ffmpeg. I’ll checkout your “Create an Image Preview from a Video” post now.

  4. Sander

    To calculate the correct duration I had to change the ‘interval’ line to:

    interval=$((($length-$starttimeseconds)/$desiredsnippets))
  5. Sander

    If you want more than 9 snippets, the sorting of the snippets is out of order (1, 10, 11, 12, 2, 3, 4…). I’ve changed this part of the code to work with leading zeros (as many as needed) to have the snippets in the correct order when they are concatenated:

    for i in $(seq -w 1 $desiredsnippets)
      do
        # Format the second marks into hh:mm:ss format
        start=$(($(($(echo $i | sed 's/^0*//')*$interval))+$starttimeseconds))
        formattedstart=$(printf "%02d:%02d:%02d\n" $(($start/3600)) $(($start%3600/60)) $(($start%60)))
        echo 'Generating preview part ' $i $formattedstart
        # Generate the snippet at calculated time
        ffmpeg -i $sourcefile -vf scale=$dimensions -preset fast -qmin 1 -qmax 1 -ss $formattedstart -t $snippetlengthinseconds $tempdir/$i.$extension &>/dev/null
    done
    
    • john nguyen

      this fixed order but
      60mins video, 30 snippets -> 2mins each snippets
      so it will start at 0:02:00 -> 0:04:00 ->
      is there have anyway to make it start at 0:00:00 (1st snippet) -> 0:02:00 (2nd snippet) -> ….

      on pornhub i see it use 12 snippets, each snippets is 1s i think, and it start from beginning of each snippets

  6. user135711

    I would change:

    extension=${destfile#*.}

    to

    extension=${destfile##*.}

    in case the file name has periods.

  7. user

    i keep getting

    list.txt: No such file or directory

    even if i create the list.txt file it gets removed when i run the script and the same error appears ?

  8. Fatih Akgun

    i get this error when i run

    $ ./preview.sh input.mp4 output.mp4
    
    list.txt: No such file or directory
    Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'input.mp4':
      Metadata:
        major_brand     : isom
        minor_version   : 512
        compatible_brands: isomiso2avc1mp41
        encoder         : Lavf56.40.101
      Duration: 00:42:01.20, start: 0.000000, bitrate: 255 kb/s
        Stream #0:0(und): Video: h264 (Constrained Baseline) (avc1 / 0x31637661), yuv420p, 480x270, 219 kb/s, 15 fps, 15 tbr, 15360 tbn, 30 tbc (default)
        Metadata:
          handler_name    : VideoHandler
        Stream #0:1(und): Audio: mp3 (mp4a / 0x6134706D), 22050 Hz, mono, s16p, 32 kb/s (default)
        Metadata:
          handler_name    : SoundHandler
    snippets/1.mp4: No such file or directory
    
  9. Fatih Akgun

    removing

    &>/dev/null

    fixed it but why ?

  10. Fatih Akgun

    btw could this script be converted in php

  11. Fatih Akgun

    ok i finnaly made it in php

    here is the file https://gist.github.com/AkgunFatih/88c2241865c25e40a50edc4e24679a94

    i only need some checks and refactor but it works :)

    • What happened to the link? Could do with this in PHP if anyone has it.

  12. Armin

    To speed things up you should put the -ss switch before the -i switch. Doing it your way is slower. You’re doing output filtering, so it takes a frame, translates it and then checks if it is in the legal range. The other way around it checks the legal range before translating the frame.
    You can also use the -thread switch with $(nproc) as argument. This spawns as many threads as there are cores in the CPU.

  13. Philip

    Mr. Walsh, this is exactly what I was looking for! Thank you!
    And @Armin, thank you. Using Linux time bringing the -ss to the front brought my real time down from 34 seconds to 4 SECONDS. Wow. Thanks.

    I was familiar with trimming clips with ffmpeg and definitely skimmed past a post or two about the -ss placement in the past but this was a real-life example and definitely made me take it more seriously

  14. Rundik

    Here is a working optimized version with faster encoding and optimized mp4 parameters which makes the file size 10 times less in my case.

    sourcefile=$1
    destfile=$2
    
    # Overly simple validation
    if [ ! -e "$sourcefile" ]; then
      echo 'Please provide an existing input file.'
      exit
    fi
    
    if [ "$destfile" == "" ]; then
      echo 'Please provide an output preview file name.'
      exit
    fi
    
    # Get video length in seconds
    length=$(ffprobe $sourcefile  -show_format 2>&1 | sed -n 's/duration=//p' | awk '{print int($0)}')
    
    # Start 20 seconds into the video to avoid opening credits (arbitrary)
    starttimeseconds=20
    
    # Mini-snippets will be 2 seconds in length
    snippetlengthinseconds=2
    
    # We'll aim for 5 snippets spread throughout the video
    desiredsnippets=5
    
    # Ensure the video is long enough to even bother previewing
    minlength=$(($snippetlengthinseconds*$desiredsnippets))
    
    # Video dimensions (these could probably be command line arguments)
    dimensions=640:-1
    
    # Temporary directory and text file where we'll store snippets
    # These will be cleaned up and removed when the preview image is generated
    tempdir=snippets
    listfile=list.txt
    
    # Display and check video length
    echo 'Video length: ' $length
    if [ "$length" -lt "$minlength" ]
    then
      echo 'Video is too short.  Exiting.'
      exit
    fi
    
    # Loop and generate video snippets
    mkdir $tempdir
    interval=$(($length/$desiredsnippets-$starttimeseconds))
    for i in $(seq 1 $desiredsnippets)
    do
        # Format the second marks into hh:mm:ss format
        start=$(($(($i*$interval))+$starttimeseconds))
        formattedstart=$(printf "%02d:%02d:%02d\n" $(($start/3600)) $(($start%3600/60)) $(($start%60)))
        echo 'Generating preview part ' $i $formattedstart
        # Generate the snippet at calculated time
        ffmpeg -i $sourcefile -vf scale=$dimensions -preset fast -qmin 1 -qmax 1 -ss $formattedstart -t $snippetlengthinseconds -threads $(nproc) $tempdir/$i.mp4
    done
    
    # Concat videos
    echo 'Generating final preview file'
    
    # Generate a text file with one snippet video location per line
    # (https://trac.ffmpeg.org/wiki/Concatenate)
    for f in $tempdir/*; do echo "file '$f'" >> $listfile; done
    
    # Concatenate the files based on the generated list
    ffmpeg -f concat -safe 0 -i $listfile -threads $(nproc) -an -tune zerolatency -x264opts bitrate=2000:vbv-maxrate=2000:vbv-bufsize=166 -vcodec libx264 -f mpegts -muxrate 2000K -y $destfile.mp4
    
    echo 'Done!  Check ' $destfile '.mp4!'
    
    # Cleanup
    rm -rf $tempdir $listfile
    
    
  15. Rundik

    Unfortunately I can’t edit my comment. Move -ss to the beginning like that for much faster speeds

    ffmpeg -ss $formattedstart -t $snippetlengthinseconds -i $sourcefile -vf scale=$dimensions -preset fast -qmin 1 -qmax 1 -threads $(nproc) $tempdir/$i.mp4
    
  16. Mod

    I’m trying to do the exact same thing but using JavaScript. By chance do you know how this is done?

  17. I’m trying to do the same exact thing but using JavaScript and Python; I’m using Django for my web site. By any chance do you know how to do it this way or can maybe point me in the right direction. Thank you very much.

  18. ARandomGuy

    Does know how to predict which frames are going to be used to let the uploader choose the video thumbnail in pornhub and similar sites? The workaround I found is to upload a video with the same number of frames with only a frame counter, check which frames are selected and then re-render the video with a title thumbnail in one of those frames. Of course, it’s a pain and it would be great if I could do the rendering only once..

  19. Vladimir Bartalos

    Hi guys, you guys are coding gods to me. Thank you very much, I had problems even to make this work but now I finally did.

    Is it please possible to use this in batch mode and without need to manually naming input output files ?

    Thank you
    Vlad

  20. Here is a working PowerShell script for Windows users.

    $sourcefile = $args[0]
    $destfile = $args[1]
    
    # Overly simple validation
    if (-not (Test-Path $sourcefile)) {
        Write-Host 'Please provide an existing input file.'
        exit
    }
    
    if ($destfile -eq "") {
        Write-Host 'Please provide an output preview file name.'
        exit
    }
    
    # Get video length in seconds
    $length = (ffprobe $sourcefile  -show_format 2>&1 | Select-String -Pattern 'duration=').Line -replace 'duration='
    $length = $length.split(".")[0]
    
    # Start 20 seconds into the video to avoid opening credits (arbitrary)
    $starttimeseconds = 20
    
    # Mini-snippets will be 2 seconds in length
    $snippetlengthinseconds = 2
    
    # We'll aim for 5 snippets spread throughout the video
    $desiredsnippets = 10
    
    # Ensure the video is long enough to even bother previewing
    $minlength = $snippetlengthinseconds * $desiredsnippets
    
    # Video dimensions (these could probably be command line arguments)
    $dimensions = "640:-1"
    
    # Temporary directory and text file where we'll store snippets
    # These will be cleaned up and removed when the preview image is generated
    $tempdir = "snippets"
    $listfile = "list.txt"
    
    # Display and check video length
    Write-Host "Video length: $length"
    Write-Host "$minlength"
    if ($minlength -gt $length) {
        Write-Host 'Video is too short. Exiting.'
        exit
    }
    
    # Loop and generate video snippets
    mkdir $tempdir
    $interval = ($length - $starttimeseconds) / $desiredsnippets - $desiredsnippets
    for ($i = 1; $i -le $desiredsnippets; $i++) {
        # Format the second marks into hh:mm:ss format
        $start = ($i * $interval) + $starttimeseconds
        $formattedstart = "{0:hh\:mm\:ss}" -f ([timespan]::FromSeconds($start))
        Write-Host "Generating preview part $i $formattedstart"
        # Generate the snippet at calculated time
        ffmpeg -ss $formattedstart -t $snippetlengthinseconds -i $sourcefile -vf scale=$dimensions -preset fast -qmin 1 -qmax 1 -threads 8 $tempdir/$i.mp4
    }
    # Concat videos
    Write-Host 'Generating final preview file'
    gci -path "./snippets"| ren -n {[regex]::replace($_.BaseName, '\d+', {"$args".PadLeft(2, '0')})}
    # Generate a text file with one snippet video location per line
    # (https://trac.ffmpeg.org/wiki/Concatenate)
    Get-ChildItem $tempdir | ForEach-Object { "file '$($_.FullName)'" } | Out-File -encoding ASCII list.txt
    # Concatenate the files based on the generated list
    ffmpeg -f concat -safe 0 -i $listfile -threads 8 -an -tune zerolatency "$destfile" | Out-Null
    
    Write-Host "Done! Check $destfile!"
    
    # Cleanup
    Remove-Item -Path $tempdir, $listfile -Recurse -Force
    

Wrap your code in <pre class="{language}"></pre> tags, link to a GitHub gist, JSFiddle fiddle, or CodePen pen to embed!