Зауважте: коли користувач завантажує відео, перше, що він бачить — прев'ю. Якщо його немає або воно неінформативне, це знижує залученість та SEO-показники. Без прев'ю сторінка відео втрачає до 30% кліків у пошуковій видачі, а Core Web Vitals страждають через відсутність LCP-оптимізації. Ми автоматизуємо витягнення прев'ю-кадрів за допомогою FFmpeg, щоб кожне відео отримувало якісний постер та sprite sheet для прогрес-бару. Наш досвід у веб-розробці понад 8 років, реалізовано 50+ проектів з відеопрев'ю на платформах від Laravel до Next.js. Зв'яжіться з нами для консультації — оцінимо ваш проект безкоштовно.
Для генерації прев'ю-кадрів важливо обрати правильний час і параметри. Основна складність — обрати інформативний кадр. Випадковий кадр часто темний або розмитий. FFmpeg вирішує це фільтром thumbnail, який аналізує кадри за метрикою SAD (Sum of Absolute Differences) і знаходить найкращий. Друга проблема — продуктивність: при великій кількості відео неефективно генерувати прев'ю синхронно. Ми використовуємо черги Laravel з Job, запускаючи генерацію після завантаження відео. Завдяки фоновій обробці сервер не блокується, а користувач одразу бачить стандартний placeholder до завершення генерації. Процес генерації прев'ю кадрів включає вибір фільтра та оптимізацію розміру.
Як згенерувати постер відео за допомогою 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 обирає найкращий кадр?
FFmpeg вміє обирати кадри за метрикою зміни через фільтр thumbnail:
ffmpeg -i input.mp4 -vf "thumbnail=300" -vframes 1 thumbnail.jpg
Згідно з документацією FFmpeg, фільтр thumbnail аналізує кожен 300-й кадр, обчислюючи SAD відносно попереднього, і обирає кадр з максимальною зміною. Це приблизно один раз на 10–12 секунд при 24 fps. Повільніше прямої вибірки за часом, але результат інформативніший.
Вибір оптимальної точки для постера
Ми рекомендуємо гібридний підхід: якщо відео містить ключову сцену (наприклад, логотип на початку), використовуйте фіксовану мітку. Інакше — автовибір. В обох випадках застосовуйте масштабування з padding для єдиного розміру.
Порівняємо методи вибору кадру:
| Параметр | Випадковий кадр | Thumbnail-фільтр |
|---|---|---|
| Швидкість | Миттєво | ~2–5 сек на хвилину відео |
| Якість | Часто темний/розмитий | Репрезентативний |
| Використання | Постер за замовчуванням | Постер для важливих відео |
Чому sprite sheet кращий за одиночні прев'ю?
Sprite sheet — одне зображення з сіткою кадрів, яке плеєр використовує для показу прев'ю при наведенні на прогрес-бар. Це скорочує кількість HTTP-запитів до одного, знижуючи навантаження на сервер і прискорюючи відгук. При довгих відео (від 10 хвилин) це критично — одиночні прев'ю генерували б десятки файлів.
Для відео довжиною 10 хвилин сітка 10×10 дає інтервал 6 секунд. Ширина одного кадру 160px, сітка 1600×900px (при 16:9 thumb). Клієнтський код обчислює позицію спрайта залежно від часу:
function getThumbnailPosition(currentTime, spriteData) {
const { columns, rows, thumbWidth, interval, duration } = spriteData;
const thumbHeight = Math.round(thumbWidth * 9 / 16);
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 };
}
При hover на прогрес-бар достатньо встановити background-image і змістити фон на обчислені координати. В середньому це зменшує кількість запитів на 90% порівняно із завантаженням окремих мініатюр.
PHP-сервіс генерації прев'ю
Повний код VideoThumbnailService
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;
$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');
}
}
Інтеграція в Laravel через Job
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);
$posterPath = "{$dir}/poster.jpg";
$service->generatePoster($inputPath, $posterPath);
$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,
]);
}
}
Метадані sprite (columns, rows, interval) зберігаються в JSONB-полі таблиці videos. Клієнт отримує їх разом з даними відео і використовує для рендерингу прев'ю на прогрес-барі.
Процес роботи
- Аналітика — вивчаємо структуру відео, вимоги до прев'ю (розмір, формат).
- Проектування — обираємо стек: FFmpeg + черга Laravel. Визначаємо параметри sprite sheet.
- Реалізація — пишемо сервіс і Job, інтегруємо з моделлю.
- Тестування — перевіряємо на реальних відео різної довжини, коригуємо евристики.
- Деплой — налаштовуємо чергу, моніторинг помилок.
Що входить в роботу
- Налаштування FFmpeg з оптимальними параметрами для постера і sprite sheet.
- Написання сервіс-класу VideoThumbnailService і Job для черги.
- Інтеграція з моделлю відео (збереження метаданих в JSONB).
- Оптимізація черг (черга high для пріоритетних відео, timeout 300 сек).
- Тестування на відео різної тривалості та роздільної здатності.
- Документація по API ендпоінтам та формату метаданих sprite.
Терміни та вартість
Терміни — від 3 до 5 днів під ключ. Вартість інтеграції починається від 200 євро. Економія на модерації контенту — до 70% витрат, що при 100 відео на місяць дає ~$500 економії. Вкладення окупаються за 2-3 місяці. Напишіть нам — оцінимо ваш проект безкоштовно.
Для наочності порівняємо підходи:
| Параметр | Одиночний постер | Sprite sheet |
|---|---|---|
| Кількість файлів | 1 | 1 |
| HTTP-запитів | 1 | 1 |
| Генерація | Швидко | Довше (аналіз усіх кадрів) |
| Використання | Обкладинка відео | Прогрес-бар прев'ю |
| Розмір файлу | Малий (до 100 КБ) | Великий (до 5 МБ) |
Ми гарантуємо якісну генерацію, що відповідає сучасним стандартам. Замовте впровадження генерації прев'ю для вашого відео-контенту — зв'яжіться з нами для консультації.







