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.







