Create a video preview as animated GIF with FFmpeg and PHP SPL

About one year ago, I had to create animated GIFs as a preview of a video for a media portal. I recently stumbled upon the code I wrote and thought it was probably worth sharing. It makes a rather unconventional use of the SPL Iterators, proving how flexible they are.

The tools

To create the video preview, we need PHP >= 5.1 with the Imagick extension and FFmpeg. FFmpeg supports most of the existing video formats, has a command line interface, and is available under the LGPL license.

The code

I created two classes: one to extract some video frames, and one to join them back into an animated GIF. The frame extractor class implements the Iterator interface, so we can specify which frames we want, and then loop through the object to get them. The thumbnail joiner class uses Imagick to read the frames (either from disk or as binary content) and join them into a .gif file.

Thumbnail_Extractor

<?php
/**
 * This class uses ffmpeg to extract frames from a video file
 *
 * @author    Lorenzo Alberton <lorenzo@ibuildings.com>
 * @copyright 2008-2009 Lorenzo Alberton
 * @license   http://www.debian.org/misc/bsd.license  BSD License (3 Clause)
 */
class Thumbnail_Extractor implements Iterator
{
    /**
     * @var string path to ffmpeg binary
     */
    protected $ffmpeg = 'ffmpeg';

    /**
     * @var string path to video
     */
    protected $video;

    /**
     * @var array frames extracted from video
     */
    protected $frames = array();

    /**
     * @var string thumbnail size
     */
    protected $size = '';

    /**
     * @var integer video length
     */
    protected $duration = 0;

    /**
     * @var boolean A switch to keep track of the end of the array
     */
    private $valid = false;

    /**
     * Constructor
     *
     * @param string $video  path to source video
     * @param array  $frames array of frames extracted [array('10%', '30%', '50%', '70%', '90%')]
     * @param string $size   frame size [format: '320x260' or array(320,260)]
     * @param string $ffmpeg path to ffmpeg binary
     */
    public function __construct($video, $frames = array(), $size = '', $ffmpeg = 'ffmpeg') {
        $this->video  = escapeshellarg($video);
        $this->ffmpeg = escapeshellcmd($ffmpeg);
        $this->duration = $this->_getDuration();
        $this->_setSizeParam($size);
        $this->_setFrames($frames);
    }

    /**
     * Parse and set the frame size args to pass to ffmpeg
     *
     * @param string|array $size frame size [format: '320x260' or array(320,260)]
     *
     * @return void
     */
    private function _setSizeParam($size) {
        if (is_array($size) && 2 == count($size)) {
            $this->size = '-s '.(int)array_shift($size).'x'.(int)array_shift($size);
        } elseif (is_string($size) && preg_match('/^\d+x\d+$/', $size)) {
            $this->size = '-s '.$size;
        }
    }

    /**
     * Init the frames array
     *
     * @param mixed $frames If integer, take a frame every X seconds;
     *                      If array, take a frame for each array value,
     *                      which can be an integer (seconds from start)
     *                      or a string (percent)
     */
    private function _setFrames($frames) {
        if (empty($frames)) {
            // throw exception?
            return;
        }
        if (is_integer($frames)) {
            // take a frame every X seconds
            $interval = $frames;
            $frames = array();
            for ($pos=0; $pos < $this->duration; $pos += $interval) {
            	$frames[] = $pos;
            }
        }
        if (!is_array($frames)) {
            // throw exception?
            return;
        }
        // init the frames array
        foreach ($frames as $frame) {
            $this->frames[$frame] = null;
        }
    }

    /**
     * Get the video duration
     *
     * @return integer
     */
    private function _getDuration() {
        $cmd = "{$this->ffmpeg} -i {$this->video} 2>&1";
        if (preg_match('/Duration: ((\d+):(\d+):(\d+))/s', `$cmd`, $time)) {
            return ($time[2] * 3600) + ($time[3] * 60) + $time[4];
        }
        return 0;
    }

    /**
     * Get a video frame from a certain point in time
     *
     * @param integer $second seconds from start
     *
     * @return string binary image contents
     */
    private function getFrame($second) {
        $image = tempnam('/tmp', 'FRAME_');
        $out = escapeshellarg($image);
        $cmd = "{$this->ffmpeg} -i {$this->video} -deinterlace -an -ss {$second} -t 00:00:01 -r 1 -y {$this->size} -vcodec mjpeg -f mjpeg {$out} 2>&1";
        `$cmd`;
        $frame = file_get_contents($image);
        @unlink($image);
        return $frame;
    }

    /**
     * Get the second
     *
     * @param mixed $second if integer, it's taken as absolute time in seconds
     *                      from the start, otherwise it's supposed to be a percentual
     *
     * @return integer
     */
    private function getSecond($second) {
        if (false !== strpos($second, '%')) {
            $percent = (int)str_replace('%', '', $second);
            return (int)($percent * $this->duration / 100);
        }
        return (int)$second;
    }

    /**
     * Return the array "pointer" to the first element
     * PHP's reset() returns false if the array has no elements
     *
     * @return void
     */
    public function rewind() {
        $this->valid = (false !== reset($this->frames));
    }

    /**
     * Return the current array element
     *
     * @return string binary image contents
     */
    public function current() {
        if (is_null(current($this->frames))) {
            $k = $this->key();
            $second = $this->getSecond($k);
            $this->frames[$k] = $this->getFrame($second + 1);
        }
        return current($this->frames);
    }

    /**
     * Return the key of the current array element
     *
     * @return mixed
     */
    public function key() {
        return key($this->frames);
    }

    /**
     * Move forward by one
     * PHP's next() returns false if there are no more elements
     *
     * @return void
     */
    public function next() {
        $this->valid = (false !== next($this->frames));
    }

    /**
     * Is the current element valid?
     *
     * @return boolean
     */
    public function valid() {
        return $this->valid;
    }
}

Thumbnail_Joiner

<?php
/**
 * This class uses Imagick to join some images into an animated gif
 *
 * @author    Lorenzo Alberton <lorenzo@ibuildings.com>
 * @copyright 2008-2009 Lorenzo Alberton
 * @license   http://www.debian.org/misc/bsd.license  BSD License (3 Clause)
 */
class Thumbnail_Joiner
{
    /**
     * @var integer delay between images (in milliseconds)
     */
    protected $delay = 50;

    /**
     * @var array
     */
    protected $images = array();

    /**
     * @param integer $delay between images
     */
    public function __construct($delay = 50) {
        $this->delay = $delay;
    }

    /**
     * Load an image from file
     *
     * @param string $filename
     *
     * @return void
     */
    public function addFile($image) {
        $this->images[] = file_get_contents($image);
    }

    /**
     * Load an image
     *
     * @param string $image binary image data
     *
     * @return void
     */
    public function add($image) {
        $this->images[] = $image;
    }

    /**
     * Generate the animated gif
     *
     * @return string binary image data
     */
    public function get() {
        $animation = new Imagick();
        $animation->setFormat('gif');
        foreach ($this->images as $image) {
            $frame = new Imagick();
            $frame->readImageBlob($image);
            $animation->addImage($frame);
            $animation->setImageDelay($this->delay);
        }
        return $animation->getImagesBlob();
    }

    /**
     * Save the animated gif to file
     *
     * @param string $outfile output file name
     *
     * @return void
     */
    public function save($outfile) {
        file_put_contents($outfile, $this->get());
    }
}

Example usage

<?php
require 'Thumbnail_Extractor.php';
require 'Thumbnail_Joiner.php';

// where ffmpeg is located, such as /usr/sbin/ffmpeg
$ffmpeg = '/usr/bin/ffmpeg';

// the input video file
$video = dirname(__FILE__) . '/sample.avi';

// extract one frame at 10% of the length, one at 30% and so on
$frames = array('10%', '30%', '50%', '70%', '90%');

// set the delay between frames in the output GIF
$joiner = new Thumbnail_Joiner(50);
// loop through the extracted frames and add them to the joiner object
foreach (new Thumbnail_Extractor($video, $frames, '200x120', $ffmpeg) as $key => $frame) {
    $joiner->add($frame);
}
$joiner->save(dirname(__FILE__).'/'.'out.gif');

As you can see, the usage is pretty easy and self-explanatory. You can select which frames to extract (or how to extract them) specifying: - an array of seconds - an array of percentages - an integer (interval in seconds between frames).
Also the output image dimensions are customisable.

Example

Source movie:

Source: www.archive.org.

Output

Extract frames from a movie and create a preview as animated GIF, using PHP, SPL, Imagick and FFmpeg - sample output



17 responses to "Create a video preview as animated GIF with FFmpeg and PHP SPL"

Congratulations, very interesting and useful.

This is a great code. Thanks.

Try extracting thumbnails at the end of the video. Choose a bit more heavy videos for example over 100mb. It should take quite more time than taking them from the start.
Then try moving the -ss before -i and see if it improves the speeds.
$cmd = \"{$this->ffmpeg} -i {$this->video} -deinterlace -an -ss {$second} -t 00:00:01 -r 1 -y {$this->size} -vcodec mjpeg -f mjpeg {$out} 2>&1\";

Looks good. I feel like i remember seeing something like this a while ago, but you've made it all very comprehensive. I will be giving this a bash soon methinks.

A great example, very useful !! Thanx.

Looks wery good super technic

Looks good. I feel like i remember seeing something like this a while ago, but you've made it all very comprehensive. I will be giving this a bash soon methinks.

Thank you Lorenzo! I've found your code very usefull though imagick extension still have to be installed on server.

This post is rockin' ! This'll save me some time! Thank you!!

great! keep the good work

Hi Anyone have this error? "no decode delegate for this image format", i installed all requirements

First off, thanks for this very useful script. I stumbled upon this article when I was given a task to achieve such. Thing is, I am a bit lost as to what values should I pass for the frames array and the delay.
I would like to extract a six (6) seconds preview of my video as an animated GIF. Basing it on my own understanding, as my video frame rate was 25 fps, I need to take...
25 (frames) * 6 (seconds) = 150
...and since I would need to show 25 frames per each second the delay would be...
1000 (milliseconds) / 25 (frames) = 40
Problem is, after waiting for a long while, what I got was a long (about 30secs) animated GIF with a slow animation. What am I missing here? I probably don\'t understand everything yet, I hope you could enlighten me on this so I could achieve my desired output.
Thanks in advance!
.crib

Wow, making video on the fly, exactly what I was looking for mate, thank you very much!

Hi very nice script, exactly what I was looking for thank you for this.
I am attempting to use it on my site. You will see it in action, hopefully, very soon :)

Seen this script and really wanted to use it, so 2 days later it took a lot for me to set up Imagick PHP extension and ffmpeg on my server, however the script rocks thank you so much!

Awesome post, thanks :) I made a small addition to Thumbnail_Joiner::get to allow for overlaying text:

public function get($text = null) { 
    $animation = new Imagick(); 
    $draw = new ImagickDraw(); 
    $draw->setGravity(imagick::GRAVITY_SOUTH); // has been set, e,g; with $gravity= imagick::GRAVITY_CENTER 
    $draw->setFillColor('#333333'); 
    $draw->setTextUnderColor('#ffffff88'); 
    $draw->setFont('Arial'); 
    $draw->setFontSize(14); 
    $animation->setFormat('gif'); 
    foreach ($this->images as $image) { 
        $frame = new Imagick(); 
        $frame->readImageBlob($image); 
        $animation->addImage($frame); 
        $animation->setImageDelay($this->delay); 
        if ($text) { 
            $animation->annotateImage($draw, 0, 25, 0, $text); 
        }
    } 
    return $animation->getImagesBlob(); 
}

Try this

<?php 
$file = "file.avi"; 
$movie = new ffmpeg_movie($file); 
$gif =new ffmpeg_animated_gif ("image.gif" , 100 , 100 , 24); 
for ( $frame = 150 ; $frame <= 200 ; $frame++) { 
    $image = $movie->getFrame($frame); 
    $gif->addFrame ($image);
}
 ?>

Lorenzo Alberton

Lorenzo Alberton Lorenzo PHP5 ZCE - Zend Certified Engineer has been working with large enterprise UK companies for the past years and is now Chief Tech Architect at DataSift. He's an international conference speaker and a long-time contributor to many open source projects. Lorenzo Alberton's profile on LinkedIN View Lorenzo Alberton's Twitter stream

Lorenzo Alberton - Sun Certified MySQL 5 Developer

Tags

AJAX, Apache, Book Review, Charset, Cheat Sheet, Data structures, Database, Firebird SQL, Hadoop, Imagick, INFORMATION_SCHEMA, JavaScript, Kafka, Linux, Message Queues, mod_rewrite, Monitoring, MySQL, NoSQL, Oracle, PDO, PEAR, Performance, PHP, PostgreSQL, Profiling, Scalability, Security, SPL, SQL Server, SQLite, Testing, Tutorial, TYPO3, Windows, Zend Framework

Buy me a book - Data Structures And Algorithm Analysis In C