Реалізація конвейера транскодирування відео (FFmpeg) на сервері
Користувачі завантажують відео в чім попало — MOV з iPhone, MKV з торрента, AVI з 2008 року. Задача бекенду — прийняти це, віддати в браузер нормальний MP4/H.264 або WebM/VP9, не блокуючи веб-процес на хвилини транскодирування.
Архітектура pipeline
Транскодирування відео — CPU-інтенсивна операція, яка тривають від секунд до десятків хвилин залежно від довжини ролика та профілю кодування. Синхронна обробка в HTTP-запиті виключена.
Правильна архітектура:
Upload → S3/локальне сховище → Job Queue → Worker → FFmpeg → Вихідне сховище → Notify
- Користувач завантажує файл — зберігаємо оригінал, повертаємо ID завдання.
- Ставимо Job в чергу.
- Worker забирає Job, запускає FFmpeg, пише прогрес в Redis.
- Після завершення обновляємо запис в БД, сповіщаємо через 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 годин.







