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