Implementing Adaptive Video Streaming (HLS with Multiple Qualities)
Direct MP4 delivery via HTTP works for short clips, breaks on long videos, weak connections, mobile. HLS (HTTP Live Streaming) — Apple's protocol, became de-facto web standard: video segmented into 2–6 second chunks, player selects quality based on connection speed.
How HLS Works
Output file structure:
master.m3u8 ← main playlist (links to all qualities)
├── 360p/
│ ├── index.m3u8 ← playlist for one quality
│ ├── seg000.ts
│ ├── seg001.ts
│ └── ...
├── 720p/
│ ├── index.m3u8
│ └── ...
└── 1080p/
└── ...
master.m3u8 contains list of streams with bandwidth, resolution, pointer to corresponding playlist. Player (HLS.js, video tag natively in Safari/iOS) auto-switches quality.
HLS Generation via FFmpeg
For single quality:
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 — segment length 4 seconds. -hls_list_size 0 — keep all segments in playlist (don't delete old — needed for VOD).
Multiple Qualities in Single FFmpeg Pass
One pass, multiple outputs — more CPU-efficient:
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 — stream index substitution. -hls_flags independent_segments — each segment independently decodable.
PHP HLS Service
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 with Progress
class GenerateHlsJob implements ShouldQueue
{
public int $timeout = 7200; // 2 hours
public int $tries = 1; // Don't auto-retry HLS tasks
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 Configuration
location /storage/videos/hls/ {
alias /var/www/storage/app/public/videos/hls/;
# CORS for player on different domain
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Headers "Range";
add_header Access-Control-Expose-Headers "Content-Range, Content-Length";
# Correct MIME types
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
# Caching: segments — long, playlist — don't cache for live
location ~* \.ts$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.m3u8$ {
expires -1;
add_header Cache-Control "no-store";
}
}
Frontend Player
HLS.js — main library for browsers without native 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, // auto quality select
abrEwmaDefaultEstimate: 1_000_000, // initial 1 Mbps estimate
});
hls.loadSource(src);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari / iOS native
video.src = src;
}
</script>
Segment Storage
HLS segments for hour video in three qualities — ~2000+ .ts files. Local disk works, but at scale move to S3-compatible storage (MinIO, AWS S3). FFmpeg can write directly to S3 via s3:// URI with libavformat S3 support.
Alternative — generate locally, then sync via aws s3 sync or rclone.
Timeline
HLS service, Job, Nginx config — 1.5–2 working days. Player with HLS.js, buffer error handling, Safari fallback — another 6–8 hours. CDN integration (CloudFront, Bunny) — separate 4–8 hours.







