Реализация генерации превью-кадров (thumbnails) для видео
Превью для видео нужно в двух сценариях: одиночный кадр как обложка (poster для <video>) и сетка кадров для временно́й шкалы (sprite sheet). Оба варианта строятся через FFmpeg, но логика немного разная.
Одиночное превью
Самый простой вариант — кадр на определённой временно́й метке:
ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 -q:v 2 thumbnail.jpg
-ss 00:00:05 — позиция (5 секунд от начала). -vframes 1 — один кадр. -q:v 2 — качество JPEG (1 = лучшее, 31 = худшее).
Лучше ставить отметку не на 0 секунд (часто чёрный кадр) и не на последнюю секунду. Хороший эвристический подход — 10% от длины видео, но не менее 3 секунд и не более 30.
Автовыбор информативного кадра
FFmpeg умеет выбирать кадры по метрике «наибольшее изменение» через фильтр thumbnail:
ffmpeg -i input.mp4 \
-vf "thumbnail=300" \
-vframes 1 \
thumbnail.jpg
thumbnail=300 — анализирует каждые 300 кадров и выбирает наиболее репрезентативный. Это примерно 10–12 секунд при 24 fps. Медленнее, чем прямая выборка по времени, но результат информативнее.
PHP-сервис генерации превью
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 фильтр создаёт grid из кадров
$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, // секунд между кадрами
'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 для временно́й шкалы
Плеер показывает пользователю превью при наведении на прогресс-бар — это sprite sheet: одно изображение с сеткой кадров. Клиент смещает background-position в зависимости от позиции курсора.
Для видео длиной 10 минут, сетка 10×10, интервал — 6 секунд. Ширина одного кадра 160px, сетка 1600×900px (при 16:9 thumb).
JavaScript для расчёта позиции:
function getThumbnailPosition(currentTime, spriteData) {
const { columns, rows, thumbWidth, interval, duration } = spriteData;
const thumbHeight = Math.round(thumbWidth * 9 / 16); // предполагаем 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 при hover на прогресс-бар:
const pos = getThumbnailPosition(hoveredTime, spriteData);
preview.style.backgroundPosition = `-${pos.x}px -${pos.y}px`;
Генерация в 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,
]);
}
}
Хранение в базе данных
ALTER TABLE videos
ADD COLUMN poster_path VARCHAR(500),
ADD COLUMN sprite_path VARCHAR(500),
ADD COLUMN sprite_data JSONB;
sprite_data хранит метаданные сетки — columns, rows, interval, thumb_width. Клиент получает их вместе с данными видео и использует для рендеринга превью на прогресс-баре.
Сроки
Генерация одиночного постера, Job, интеграция с моделью — 3–4 часа. Sprite sheet + клиентский JS для прогресс-бара — ещё 4–6 часов.







