Реализация конвейера транскодирования видео (FFmpeg) на сервере
Пользователи загружают видео в чём попало — MOV с iPhone, MKV с торрента, AVI из 2008 года. Задача бэкенда — принять это, отдать в браузер нормальный MP4/H.264 или WebM/VP9, не блокируя веб-процесс на минуты транскодирования.
Архитектура пайплайна
Транскодирование видео — CPU-интенсивная операция, которая длится от секунд до десятков минут в зависимости от длины ролика и профиля кодирования. Синхронная обработка в HTTP-запросе исключена.
Правильная архитектура:
Upload → S3/локальный storage → Job Queue → Worker → FFmpeg → Output storage → 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 часов. Полный пайплайн с WebSocket-уведомлениями о готовности — дополнительно 3–5 часов.







