Реалізація адаптивного стрімінга відео (HLS з кількома якостями)
Пряма відправка MP4 через HTTP працює для коротких роликів, але ламається на довгих відео, слабких з'єднаннях та мобільних пристроях. HLS (HTTP Live Streaming) — протокол Apple, який став де-факто стандартом у вебі: відео нарізається на сегменти по 2–6 секунд, плеєр вибирає якість залежно від швидкості з'єднання.
Як влаштований HLS
Структура вихідних файлів:
master.m3u8 ← головний плейлист (посилання на всі якості)
├── 360p/
│ ├── index.m3u8 ← плейлист однієї якості
│ ├── seg000.ts
│ ├── seg001.ts
│ └── ...
├── 720p/
│ ├── index.m3u8
│ └── ...
└── 1080p/
└── ...
master.m3u8 містить список потоків з пропускною спроможністю, розрішенням та указівником на відповідний плейлист. Плеєр (HLS.js, video-тег нативно в Safari/iOS) автоматично переключається між якостями.
Генерація HLS через FFmpeg
Для однієї якості:
ffmpeg -i input.mp4 \
-c:v libx264 -crf 23 -preset fast \
-c:a aac -b:a 128k \
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:black" \
-hls_time 4 \
-hls_list_size 0 \
-hls_segment_filename "720p/seg%03d.ts" \
720p/index.m3u8
-hls_time 4 — довжина сегменту 4 секунди. -hls_list_size 0 — зберігати всі сегменти в плейлисті (не видаляти старі — потрібно для VOD).
Генерація кількох якостей одним вызовом FFmpeg
Один проход, кілька виходів — ефективніше по CPU:
ffmpeg -i input.mp4 \
-map 0:v:0 -map 0:a:0 \
-map 0:v:0 -map 0:a:0 \
-map 0:v:0 -map 0:a:0 \
\
-c:v:0 libx264 -crf 28 -preset fast \
-vf:v:0 "scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2:black" \
-b:v:0 600k -maxrate:v:0 800k \
-c:a:0 aac -b:a:0 96k \
\
-c:v:1 libx264 -crf 23 -preset fast \
-vf:v:1 "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:black" \
-b:v:1 2500k -maxrate:v:1 3000k \
-c:a:1 aac -b:a:1 128k \
\
-c:v:2 libx264 -crf 22 -preset medium \
-vf:v:2 "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black" \
-b:v:2 5000k -maxrate:v:2 6000k \
-c:a:2 aac -b:a:2 192k \
\
-hls_time 4 -hls_list_size 0 -hls_flags independent_segments \
-hls_segment_filename "360p/seg%03d.ts" \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
-master_pl_name master.m3u8 \
%v/index.m3u8
%v — підстановка індексу потоку. -hls_flags independent_segments — кожен сегмент можна декодувати незалежно.
PHP-сервіс HLS
namespace App\Services;
class HlsService
{
private const PROFILES = [
'360p' => ['w' => 640, 'h' => 360, 'vbr' => '600k', 'abr' => '96k', 'crf' => 28, 'preset' => 'fast'],
'720p' => ['w' => 1280, 'h' => 720, 'vbr' => '2500k', 'abr' => '128k', 'crf' => 23, 'preset' => 'fast'],
'1080p' => ['w' => 1920, 'h' => 1080, 'vbr' => '5000k', 'abr' => '192k', 'crf' => 22, 'preset' => 'medium'],
];
public function generateHls(string $inputPath, string $outputDir): string
{
foreach (array_keys(self::PROFILES) as $profile) {
@mkdir("{$outputDir}/{$profile}", 0755, true);
}
$mapArgs = [];
$profileArgs = [];
$streamMap = [];
$idx = 0;
foreach (self::PROFILES as $name => $p) {
$mapArgs[] = '-map 0:v:0 -map 0:a:0';
$scaleFilter = "scale={$p['w']}:{$p['h']}:force_original_aspect_ratio=decrease,"
. "pad={$p['w']}:{$p['h']}:(ow-iw)/2:(oh-ih)/2:black";
$profileArgs[] = implode(' ', [
"-c:v:{$idx} libx264 -crf {$p['crf']} -preset {$p['preset']}",
"-vf:v:{$idx} " . escapeshellarg($scaleFilter),
"-b:v:{$idx} {$p['vbr']} -maxrate:v:{$idx} {$p['vbr']}",
"-c:a:{$idx} aac -b:a:{$idx} {$p['abr']}",
]);
$streamMap[] = "v:{$idx},a:{$idx}";
$idx++;
}
$hlsSegPath = escapeshellarg("{$outputDir}/%v/seg%03d.ts");
$masterPath = escapeshellarg("{$outputDir}/master.m3u8");
$cmd = implode(' ', [
'ffmpeg -y',
'-i ' . escapeshellarg($inputPath),
implode(' ', $mapArgs),
implode(' ', $profileArgs),
'-hls_time 4 -hls_list_size 0',
'-hls_flags independent_segments',
"-hls_segment_filename {$hlsSegPath}",
'-var_stream_map ' . escapeshellarg(implode(' ', $streamMap)),
"-master_pl_name master.m3u8",
escapeshellarg("{$outputDir}/%v/index.m3u8"),
'2>&1',
]);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
throw new \RuntimeException(
"HLS generation failed:\n" . implode("\n", $output)
);
}
return "{$outputDir}/master.m3u8";
}
}
Job з прогресом
class GenerateHlsJob implements ShouldQueue
{
public int $timeout = 7200; // 2 години
public int $tries = 1; // HLS-задачі не повторюємо автоматично
public function __construct(private int $videoId) {}
public function handle(HlsService $hls): void
{
$video = Video::findOrFail($this->videoId);
$inputPath = Storage::disk('videos')->path($video->original_path);
$outputDir = Storage::disk('videos')->path("hls/{$video->id}");
$video->update(['hls_status' => 'processing']);
try {
$masterPath = $hls->generateHls($inputPath, $outputDir);
$video->update([
'hls_status' => 'ready',
'hls_master' => "hls/{$video->id}/master.m3u8",
'hls_ready_at' => now(),
]);
VideoHlsReady::dispatch($video);
} catch (\Throwable $e) {
$video->update(['hls_status' => 'failed']);
throw $e;
}
}
}
Nginx для відправки HLS
location /storage/videos/hls/ {
alias /var/www/storage/app/public/videos/hls/;
# CORS для плеєра на іншому домені
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Headers "Range";
add_header Access-Control-Expose-Headers "Content-Range, Content-Length";
# Правильні MIME-типи
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
# Кешування: сегменти — надовго, плейлист — не кешуємо для live
location ~* \.ts$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.m3u8$ {
expires -1;
add_header Cache-Control "no-store";
}
}
Плеєр на фронтенді
HLS.js — основна бібліотека для браузерів без нативної підтримки HLS (Chrome, Firefox):
<video id="video" controls preload="none"
poster="/storage/videos/thumbnails/1/poster.jpg"></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
const video = document.getElementById('video');
const src = '/storage/videos/hls/1/master.m3u8';
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1, // авто-вибір якості
abrEwmaDefaultEstimate: 1_000_000, // початкова оцінка полоси 1 Mbps
});
hls.loadSource(src);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari / iOS нативно
video.src = src;
}
</script>
Зберігання сегментів
HLS-сегменти для часового відео в трьох якостях — це ~2000+ файлів .ts. Локальний диск працює, але при масштабуванні переходять на S3-сумісне сховище (MinIO, AWS S3). FFmpeg умеет писати напрямки в S3 через s3:// URI при наявності libavformat з підтримкою S3.
Альтернатива — генерувати локально, потім синхронізувати через aws s3 sync або rclone.
Терміни
HLS-сервіс, Job, Nginx-конфіг — 1.5–2 робочі дні. Плеєр з HLS.js, обробка помилок буферизації, fallback для Safari — ще 6–8 годин. Інтеграція з CDN (CloudFront, Bunny) — окрема робота на 4–8 годин.







