Реалізація конвеєра транскодування відео (FFmpeg) на сервері

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

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

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

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація конвеєра транскодування відео (FFmpeg) на сервері
Складна
~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

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

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

Архітектура pipeline

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

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

Upload → S3/локальне сховище → Job Queue → Worker → FFmpeg → Вихідне сховище → 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 годин. Повний pipeline з WebSocket-сповіщеннями про готовність — додатково 3–5 годин.