Video preview frame thumbnail generation

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Showing 1 of 1 servicesAll 2065 services
Video preview frame thumbnail generation
Medium
from 1 business day to 3 business days
FAQ
Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Implementing Video Preview Frame Generation (Thumbnails)

Video previews needed in two scenarios: single frame as cover (poster for <video>) and frame grid for timeline (sprite sheet). Both built via FFmpeg, but logic slightly different.

Single Preview

Simplest variant — frame at specific timestamp:

ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 -q:v 2 thumbnail.jpg

-ss 00:00:05 — position (5 seconds from start). -vframes 1 — one frame. -q:v 2 — JPEG quality (1 = best, 31 = worst).

Better place mark not at 0 seconds (often black frame) and not at last second. Good heuristic — 10% of video length, but not less than 3 seconds and not more than 30.

Auto-select Informative Frame

FFmpeg can select frames by "most change" metric via thumbnail filter:

ffmpeg -i input.mp4 \
  -vf "thumbnail=300" \
  -vframes 1 \
  thumbnail.jpg

thumbnail=300 — analyzes every 300 frames and selects most representative. About 10–12 seconds at 24 fps. Slower than direct time selection, but result more informative.

PHP Service for Preview Generation

namespace App\Services;

class VideoThumbnailService
{
    public function generatePoster(
        string $videoPath,
        string $outputPath,
        ?float $offsetSeconds = null,
        int    $width         = 1280,
        int    $height        = 720
    ): void {
        $duration = $this->getVideoDuration($videoPath);
        $offset   = $offsetSeconds ?? max(3.0, $duration * 0.1);
        $offset   = min($offset, $duration - 1.0);

        $scaleFilter = "scale={$width}:{$height}:force_original_aspect_ratio=decrease,"
            . "pad={$width}:{$height}:(ow-iw)/2:(oh-ih)/2:black";

        $cmd = implode(' ', [
            'ffmpeg -y',
            '-ss ' . number_format($offset, 3, '.', ''),
            '-i ' . escapeshellarg($videoPath),
            '-vframes 1',
            "-vf " . escapeshellarg($scaleFilter),
            '-q:v 3',
            escapeshellarg($outputPath),
            '2>&1',
        ]);

        exec($cmd, $output, $exitCode);

        if ($exitCode !== 0 || !file_exists($outputPath)) {
            throw new \RuntimeException(
                "Thumbnail generation failed: " . implode("\n", $output)
            );
        }
    }

    public function generateSpriteSheet(
        string $videoPath,
        string $outputPath,
        int    $columns    = 10,
        int    $rows       = 10,
        int    $thumbWidth = 160
    ): array {
        $duration    = $this->getVideoDuration($videoPath);
        $totalFrames = $columns * $rows;
        $interval    = $duration / $totalFrames;

        // tile filter creates grid of frames
        $fps    = 1 / $interval;
        $filter = "fps={$fps},scale={$thumbWidth}:-1,tile={$columns}x{$rows}";

        $cmd = implode(' ', [
            'ffmpeg -y',
            '-i ' . escapeshellarg($videoPath),
            "-vf " . escapeshellarg($filter),
            '-vframes 1',
            '-q:v 5',
            escapeshellarg($outputPath),
            '2>&1',
        ]);

        exec($cmd, $output, $exitCode);

        if ($exitCode !== 0) {
            throw new \RuntimeException(implode("\n", $output));
        }

        return [
            'path'        => $outputPath,
            'columns'     => $columns,
            'rows'        => $rows,
            'thumb_width' => $thumbWidth,
            'interval'    => $interval,   // seconds between frames
            'duration'    => $duration,
        ];
    }

    public function getVideoDuration(string $path): float
    {
        $cmd    = "ffprobe -v error -show_entries format=duration -of csv=p=0 "
            . escapeshellarg($path);
        $output = shell_exec($cmd);
        return (float) trim($output ?? '0');
    }
}

Sprite Sheet for Timeline

Player shows user preview on progress bar hover — sprite sheet: one image with frame grid. Client shifts background-position depending on cursor position.

For 10-minute video, 10×10 grid, interval — 6 seconds. Thumb width 160px, grid 1600×900px (at 16:9 thumb).

JavaScript to calculate position:

function getThumbnailPosition(currentTime, spriteData) {
    const { columns, rows, thumbWidth, interval, duration } = spriteData;
    const thumbHeight = Math.round(thumbWidth * 9 / 16); // assume 16:9

    const frameIndex = Math.min(
        Math.floor(currentTime / interval),
        columns * rows - 1
    );

    const col = frameIndex % columns;
    const row = Math.floor(frameIndex / columns);

    return {
        x: col * thumbWidth,
        y: row * thumbHeight,
        width:  thumbWidth,
        height: thumbHeight,
    };
}

CSS:

.seek-preview {
    background-image: url('/storage/videos/sprite.jpg');
    background-repeat: no-repeat;
    width: 160px;
    height: 90px;
}

JS on progress bar hover:

const pos   = getThumbnailPosition(hoveredTime, spriteData);
preview.style.backgroundPosition = `-${pos.x}px -${pos.y}px`;

Generation in Job

// app/Jobs/GenerateVideoThumbnailsJob.php
class GenerateVideoThumbnailsJob implements ShouldQueue
{
    public int $timeout = 300;
    public int $tries   = 2;

    public function __construct(private int $videoId) {}

    public function handle(VideoThumbnailService $service): void
    {
        $video     = Video::findOrFail($this->videoId);
        $inputPath = Storage::disk('videos')->path($video->original_path);
        $dir       = Storage::disk('videos')->path('thumbnails/' . $video->id);

        @mkdir($dir, 0755, true);

        // Poster
        $posterPath = "{$dir}/poster.jpg";
        $service->generatePoster($inputPath, $posterPath);

        // Sprite sheet
        $spritePath = "{$dir}/sprite.jpg";
        $spriteData = $service->generateSpriteSheet($inputPath, $spritePath);

        $video->update([
            'poster_path'    => "thumbnails/{$video->id}/poster.jpg",
            'sprite_path'    => "thumbnails/{$video->id}/sprite.jpg",
            'sprite_data'    => $spriteData,
        ]);
    }
}

Database Storage

ALTER TABLE videos
    ADD COLUMN poster_path  VARCHAR(500),
    ADD COLUMN sprite_path  VARCHAR(500),
    ADD COLUMN sprite_data  JSONB;

sprite_data stores grid metadata — columns, rows, interval, thumb_width. Client gets them with video data and uses for progress bar preview rendering.

Timeline

Single poster generation, Job, model integration — 3–4 hours. Sprite sheet + client JS for progress bar — another 4–6 hours.