Adaptive video streaming with HLS and multiple quality levels

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Showing 1 of 1 servicesAll 2065 services
Adaptive video streaming with HLS and multiple quality levels
Complex
~5 business days
FAQ
Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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.