Зауважте: коли користувач завантажує відео, перше, що він бачить — прев'ю. Якщо його немає або воно неінформативне, це знижує залученість та 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 МБ) |
Ми гарантуємо якісну генерацію, що відповідає сучасним стандартам. Замовте впровадження генерації прев'ю для вашого відео-контенту — зв'яжіться з нами для консультації.







