Реализация конвейера транскодирования видео (FFmpeg) на сервере

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация конвейера транскодирования видео (FFmpeg) на сервере
Сложная
~5 рабочих дней
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Реализация конвейера транскодирования видео (FFmpeg) на сервере

Пользователи загружают видео в чём попало — MOV с iPhone, MKV с торрента, AVI из 2008 года. Задача бэкенда — принять это, отдать в браузер нормальный MP4/H.264 или WebM/VP9, не блокируя веб-процесс на минуты транскодирования.

Архитектура пайплайна

Транскодирование видео — CPU-интенсивная операция, которая длится от секунд до десятков минут в зависимости от длины ролика и профиля кодирования. Синхронная обработка в HTTP-запросе исключена.

Правильная архитектура:

Upload → S3/локальный storage → Job Queue → Worker → FFmpeg → Output storage → Notify
  1. Пользователь загружает файл — сохраняем оригинал, возвращаем ID задачи.
  2. Ставим Job в очередь.
  3. Worker забирает Job, запускает FFmpeg, пишет прогресс в Redis.
  4. После завершения обновляем запись в БД, уведомляем через WebSocket или polling.

Требования к серверу

FFmpeg должен быть собран с нужными кодеками:

ffmpeg -version
ffmpeg -codecs | grep -E 'h264|hevc|vp9|aac|opus'

Для Ubuntu/Debian достаточен пакет из репозитория:

apt install ffmpeg

На некоторых хостингах (shared) FFmpeg недоступен — нужен VPS или выделенный сервер. Минимально рекомендуемая конфигурация для транскодирования 1080p: 4 CPU, 4 GB RAM.

Профили транскодирования

// config/transcoding.php
return [
    'profiles' => [
        '360p' => [
            'width'     => 640,
            'height'    => 360,
            'video_br'  => '600k',
            'audio_br'  => '96k',
            'preset'    => 'fast',
            'crf'       => 28,
        ],
        '720p' => [
            'width'     => 1280,
            'height'    => 720,
            'video_br'  => '2500k',
            'audio_br'  => '128k',
            'preset'    => 'fast',
            'crf'       => 23,
        ],
        '1080p' => [
            'width'     => 1920,
            'height'    => 1080,
            'video_br'  => '5000k',
            'audio_br'  => '192k',
            'preset'    => 'medium',
            'crf'       => 22,
        ],
    ],
];

crf (Constant Rate Factor) — основной параметр качества для H.264: 18 = почти без потерь, 28 = приемлемое качество при малом размере. preset влияет на скорость кодирования vs размер файла.

Сервис запуска FFmpeg

namespace App\Services;

class FfmpegService
{
    public function transcode(
        string $inputPath,
        string $outputPath,
        array  $profile,
        ?callable $onProgress = null
    ): void {
        $width   = $profile['width'];
        $height  = $profile['height'];
        $videoBr = $profile['video_br'];
        $audioBr = $profile['audio_br'];
        $preset  = $profile['preset'];
        $crf     = $profile['crf'];

        // scale с сохранением соотношения сторон, padding до нужного размера
        $scaleFilter = "scale={$width}:{$height}:force_original_aspect_ratio=decrease,"
            . "pad={$width}:{$height}:(ow-iw)/2:(oh-ih)/2:black";

        $cmd = implode(' ', [
            'ffmpeg -y',
            "-i " . escapeshellarg($inputPath),
            "-vf " . escapeshellarg($scaleFilter),
            "-c:v libx264",
            "-preset {$preset}",
            "-crf {$crf}",
            "-maxrate {$videoBr}",
            "-bufsize " . (intval($videoBr) * 2) . "k",
            "-c:a aac",
            "-b:a {$audioBr}",
            "-movflags +faststart", // moov atom в начало файла — критично для стриминга
            "-progress pipe:1",     // прогресс в stdout
            "-loglevel error",
            escapeshellarg($outputPath),
        ]);

        $descriptors = [
            0 => ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];

        $proc = proc_open($cmd, $descriptors, $pipes);
        if (!is_resource($proc)) {
            throw new \RuntimeException("Failed to start FFmpeg");
        }

        fclose($pipes[0]);

        // Читаем прогресс построчно
        while (!feof($pipes[1])) {
            $line = fgets($pipes[1]);
            if ($line && $onProgress) {
                $onProgress($this->parseProgress($line));
            }
        }

        $stderr   = stream_get_contents($pipes[2]);
        $exitCode = proc_close($proc);

        if ($exitCode !== 0) {
            throw new \RuntimeException("FFmpeg failed: {$stderr}");
        }
    }

    private function parseProgress(string $line): array
    {
        // FFmpeg -progress pipe:1 выдаёт "key=value\n"
        if (str_contains($line, '=')) {
            [$key, $value] = explode('=', trim($line), 2);
            return [$key => $value];
        }
        return [];
    }

    public function getDuration(string $path): float
    {
        $cmd    = "ffprobe -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($path);
        $output = shell_exec($cmd);
        return (float) trim($output ?? '0');
    }
}

Job транскодирования

// app/Jobs/TranscodeVideoJob.php
class TranscodeVideoJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout  = 3600; // 1 час
    public int $tries    = 2;
    public int $backoff  = 60;

    public function __construct(
        private int    $videoId,
        private string $profile
    ) {}

    public function handle(FfmpegService $ffmpeg): void
    {
        $video      = Video::findOrFail($this->videoId);
        $inputPath  = Storage::disk('videos')->path($video->original_path);
        $profileCfg = config("transcoding.profiles.{$this->profile}");

        $outputFile = pathinfo($video->original_path, PATHINFO_FILENAME)
            . "_{$this->profile}.mp4";
        $outputPath = Storage::disk('videos')->path("transcoded/{$outputFile}");

        @mkdir(dirname($outputPath), 0755, true);

        $video->update(['status' => 'processing', 'progress' => 0]);

        $duration = $ffmpeg->getDuration($inputPath);

        $ffmpeg->transcode(
            $inputPath,
            $outputPath,
            $profileCfg,
            function (array $progress) use ($video, $duration) {
                if (isset($progress['out_time_us']) && $duration > 0) {
                    $pct = min(100, round(
                        ($progress['out_time_us'] / 1_000_000) / $duration * 100
                    ));
                    // Обновляем прогресс не чаще раза в 5 секунд
                    Cache::put("video_progress_{$video->id}", $pct, 30);
                }
            }
        );

        $video->variants()->create([
            'profile'  => $this->profile,
            'path'     => "transcoded/{$outputFile}",
            'size'     => filesize($outputPath),
        ]);

        // Если все профили готовы — меняем статус
        $ready = $video->variants()->pluck('profile')->toArray();
        $all   = array_keys(config('transcoding.profiles'));
        if (!array_diff($all, $ready)) {
            $video->update(['status' => 'ready']);
            VideoTranscodingCompleted::dispatch($video);
        }
    }

    public function failed(\Throwable $e): void
    {
        Video::find($this->videoId)?->update(['status' => 'failed']);
        Log::error("Transcoding failed for video {$this->videoId}: {$e->getMessage()}");
    }
}

Диспатч нескольких профилей

// В контроллере после загрузки
$video = Video::create([
    'original_path' => $path,
    'status'        => 'queued',
]);

foreach (array_keys(config('transcoding.profiles')) as $profile) {
    TranscodeVideoJob::dispatch($video->id, $profile)
        ->onQueue('transcoding')
        ->delay(now()->addSeconds(2)); // небольшая задержка между Job'ами
}

Очередь transcoding должна обрабатываться отдельным worker-процессом с ограниченным числом параллельных задач. Для Supervisor:

[program:transcoding-worker]
command=php artisan queue:work --queue=transcoding --max-jobs=1 --sleep=3 --timeout=3600
numprocs=2
autostart=true
autorestart=true

max-jobs=1 на воркер предотвращает перегрузку CPU при параллельном транскодировании.

Оптимизация для стриминга

-movflags +faststart — обязательный флаг. Он перемещает атом метаданных (moov) в начало MP4-файла, что позволяет браузеру начать воспроизведение до полной загрузки. Без него видео нельзя начать смотреть, пока не скачается весь файл.

Проверка:

ffprobe -v quiet -print_format json -show_format output.mp4 | grep moov
# или
mp4info output.mp4 | grep "moov"

Сроки

Настройка FFmpeg-сервиса, профилей, Job с прогрессом — 1 рабочий день (8–10 часов). Endpoint для polling прогресса, модели VideoVariant, Supervisor-конфиг — ещё 4–6 часов. Полный пайплайн с WebSocket-уведомлениями о готовности — дополнительно 3–5 часов.