Реалізація адаптивного стримінгу відео (HLS з кількома якостями)

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація адаптивного стримінгу відео (HLS з кількома якостями)
Складна
~5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація адаптивного стрімінга відео (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 годин.