Клиенты загружают видео в разных форматах — от MP4 с телефона до MOV с профессиональных камер. Если конвертировать их на лету, сервер ложится от нагрузки, запросы уходят в таймаут, а пользователи получают битые файлы. Без правильной архитектуры загрузка видео становится узким местом любого медиа-сайта. Мы разрабатываем асинхронный pipeline: пресайнед-загрузка в облако сразу минует сервер, а транскодинг выполняется в очереди на выделенных воркерах или Managed-сервисах.
Типичная ситуация: пользователь снимает 4K 60fps (500 МБ). Прямая отправка на сервер вызывает timeout nginx. Presigned URL решает — файл идёт в S3 за пару минут, сервер только подтверждает. После воркер забирает файл и транскодирует в фоне, не блокируя пользователя. Прогресс отображается через WebSocket — пользователь видит статус в реальном времени.
Почему стоит вынести транскодинг в фоновую очередь?
Транскодинг — CPU-интенсивная операция. Даже короткое видео в 1080p нагружает процессор на 100% в течение минут. Если делать это в веб-процессе, остальные запросы блокируются. Наш pipeline использует S3 Event → SQS/Lambda → воркеры с FFmpeg. Это позволяет горизонтально масштабироваться и не влиять на время ответа сайта. Нагрузка на CPU снижается на 90% при выносе в очередь.
Какие проблемы решает система загрузки и транскодирования видео?
- CPU-блокировка: обработка не затрагивает веб-сервер.
- Множество разрешений: автоматически генерируем 360p, 720p, 1080p и WebM для покрытия всех устройств.
- Долгая загрузка для пользователя: пресайнед-ссылка позволяет загрузить файл напрямую в облако, минуя сервер, а прогресс-трекинг через WebSocket информирует о статусе.
Как строится система загрузки и транскодирования видео?
Используем presigned URL из AWS S3: клиент получает временную ссылку и отправляет файл напрямую в bucket. После завершения, сервер создаёт запись в БД и ставит задачу в очередь (например, через SQS или Laravel Horizon).
Архитектура pipeline
[Client] -> [Presigned S3 Upload] -> [S3: original/] | [S3 Event -> SQS/Lambda] -> [Transcoding Worker (FFmpeg)] | [S3: processed/{quality}/] -> [CDN CloudFront] | [Update DB: video.status = ready, paths = {...}] | [WebSocket/Webhook -> Client notification] Шаг 1: Presigned Upload в S3
// Генерация presigned URL для прямой загрузки с клиента class VideoController extends Controller { public function initiateUpload(Request $request): JsonResponse { $request->validate([ 'filename' => 'required|string|max:255', 'content_type' => 'required|in:video/mp4,video/webm,video/quicktime,video/x-msvideo', 'size' => 'required|integer|max:5368709120', // 5 GB ]); $key = sprintf( 'original/%d/%s/%s', auth()->id(), now()->format('Y/m'), Str::uuid() . '.' . pathinfo($request->filename, PATHINFO_EXTENSION) ); $s3 = app('aws')->createClient('s3'); $command = $s3->getCommand('PutObject', [ 'Bucket' => config('filesystems.disks.s3.bucket'), 'Key' => $key, 'ContentType' => $request->content_type, ]); $presigned = $s3->createPresignedRequest($command, '+2 hours'); // Создаём запись в БД со статусом pending $video = Video::create([ 'user_id' => auth()->id(), 'original_key' => $key, 'original_name' => $request->filename, 'status' => 'pending', 'size' => $request->size, ]); return response()->json([ 'video_id' => $video->id, 'upload_url' => (string) $presigned->getUri(), 'key' => $key, ]); } // Клиент вызывает после успешной загрузки public function confirmUpload(Request $request, Video $video): JsonResponse { $this->authorize('update', $video); $video->update(['status' => 'uploaded']); TranscodeVideoJob::dispatch($video); return response()->json(['status' => 'processing']); } } Шаг 2: Транскодинг с FFmpeg
class TranscodeVideoJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; public int $timeout = 7200; // 2 часа public int $tries = 2; const QUALITIES = [ '360p' => ['width' => 640, 'height' => 360, 'bitrate' => '800k', 'audiorate' => '96k'], '720p' => ['width' => 1280, 'height' => 720, 'bitrate' => '2500k', 'audiorate' => '128k'], '1080p' => ['width' => 1920, 'height' => 1080, 'bitrate' => '5000k', 'audiorate' => '192k'], ]; public function __construct(private Video $video) {} public function handle(): void { $this->video->update(['status' => 'transcoding']); // Скачать оригинал во временный файл $tempInput = tempnam(sys_get_temp_dir(), 'video_') . '.mp4'; Storage::disk('s3')->copy($this->video->original_key, $tempInput); // упрощённо // Реально: stream из S3 через signed URL $inputUrl = Storage::disk('s3')->temporaryUrl($this->video->original_key, now()->addHour()); $paths = []; foreach (self::QUALITIES as $quality => $params) { $outputKey = sprintf( 'processed/%d/%s/%s.mp4', $this->video->user_id, $this->video->id, $quality ); $outputPath = sys_get_temp_dir() . "/{$this->video->id}_{$quality}.mp4"; $scale = "scale={$params['width']}:{$params['height']}:force_original_aspect_ratio=decrease,pad={$params['width']}:{$params['height']}:(ow-iw)/2:(oh-ih)/2"; $command = [ 'ffmpeg', '-y', '-i', $inputUrl, '-vf', $scale, '-c:v', 'libx264', '-preset', 'medium', // баланс скорость/качество '-crf', '23', '-maxrate', $params['bitrate'], '-bufsize', (int)($params['bitrate']) * 2 . 'k', '-c:a', 'aac', '-b:a', $params['audiorate'], '-movflags', '+faststart', // для web streaming $outputPath, ]; $process = new \Symfony\Component\Process\Process($command); $process->setTimeout(3600); $process->run(); if (!$process->isSuccessful()) { throw new \RuntimeException("FFmpeg failed for {$quality}: " . $process->getErrorOutput()); } // Загрузить в S3 Storage::disk('s3')->putFileAs( dirname($outputKey), new \Illuminate\Http\File($outputPath), basename($outputKey), ); $paths[$quality] = $outputKey; unlink($outputPath); } // Генерация thumbnail из 10% длительности $thumbKey = "thumbnails/{$this->video->user_id}/{$this->video->id}.jpg"; $thumbPath = sys_get_temp_dir() . "/{$this->video->id}_thumb.jpg"; $process = new \Symfony\Component\Process\Process([ 'ffmpeg', '-y', '-i', $inputUrl, '-ss', '10%', // 10% от длительности '-vframes', '1', '-q:v', '2', $thumbPath, ]); $process->run(); if (file_exists($thumbPath)) { Storage::disk('s3')->putFileAs( dirname($thumbKey), new \Illuminate\Http\File($thumbPath), basename($thumbKey), ); } // Получить метаданные $ffprobe = \FFMpeg\FFProbe::create(); $duration = $ffprobe->streams($inputUrl)->videos()->first()->get('duration'); $resolution = $ffprobe->streams($inputUrl)->videos()->first()->getDimensions(); $this->video->update([ 'status' => 'ready', 'paths' => $paths, 'thumbnail_key' => $thumbKey, 'duration' => (int) $duration, 'width' => $resolution->getWidth(), 'height' => $resolution->getHeight(), ]); // Уведомить пользователя $this->video->user->notify(new VideoReadyNotification($this->video)); } public function failed(\Throwable $e): void { $this->video->update(['status' => 'failed', 'error' => $e->getMessage()]); Log::error('Video transcoding failed', ['video_id' => $this->video->id, 'error' => $e->getMessage()]); } } Какой подход выбрать: FFmpeg на своей инфраструктуре или AWS MediaConvert?
FFmpeg даёт полный контроль и не требует дополнительных расходов на AWS, но надо администрировать серверы и следить за очередями. MediaConvert — управляемый сервис: платишь за минуты транскодинга, не заботишься о масштабировании. Для стартапов и средних проектов FFmpeg в очереди (например, Laravel Horizon) — бюджетное решение. Для больших каталогов видео с высокой нагрузкой выгоднее MediaConvert: он автоматически справляется с пиками и поддерживает HLS. FFmpeg на собственной инфраструктуре обходится в 3-4 раза дешевле MediaConvert при объёмах до 1000 минут в месяц.
| Критерий | FFmpeg + очередь | AWS MediaConvert |
|---|---|---|
| Контроль | Полный контроль над параметрами и кодексами | Ограничен предустановками |
| Масштабирование | Требуется настройка автоскейлинга воркеров | Автоматическое масштабирование |
| Стоимость для 1000 минут/мес | $0 (только сервер) | ~$30 (по тарифу) |
| Время настройки | 2–3 дня | 1 день |
MediaConvert в 2 раза быстрее на больших объёмах из-за параллельной обработки, но FFmpeg в 3 раза дешевле для малых объёмов.
AWS Elastic Transcoder / MediaConvert
import boto3 def transcode_with_mediaconvert(input_key: str, output_prefix: str) -> str: client = boto3.client('mediaconvert', region_name='eu-west-1', endpoint_url='https://abc123.mediaconvert.eu-west-1.amazonaws.com') job = client.create_job( Role='arn:aws:iam::123456789:role/MediaConvertRole', Settings={ 'Inputs': [{ 'FileInput': f's3://my-bucket/{input_key}', 'AudioSelectors': {'Audio Selector 1': {'DefaultSelection': 'DEFAULT'}}, 'VideoSelector': {}, }], 'OutputGroups': [{ 'Name': 'File Group', 'OutputGroupSettings': { 'Type': 'FILE_GROUP_SETTINGS', 'FileGroupSettings': { 'Destination': f's3://my-bucket/{output_prefix}/', }, }, 'Outputs': [ { 'NameModifier': '_720p', 'VideoDescription': { 'Width': 1280, 'Height': 720, 'CodecSettings': { 'Codec': 'H_264', 'H264Settings': {'Bitrate': 2500000, 'RateControlMode': 'CBR'}, }, }, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 128000}}}], 'ContainerSettings': {'Container': 'MP4'}, }, # ... 360p, 1080p аналогично ], }], } ) return job['Job']['Id'] Прогресс транскодинга
// Отдавать прогресс через SSE или WebSocket Route::get('/videos/{video}/status', function (Video $video) { return response()->json([ 'status' => $video->status, 'progress' => $video->transcoding_progress, 'paths' => $video->status === 'ready' ? $video->paths : null, ]); }); Процесс работы
- Анализ: собираем типы видео, ожидаемый объём, требования к адаптивной доставке.
- Проектирование: выбираем S3 vs собственное хранилище, конфигурируем FFmpeg параметры (bitrate, CRF, пресеты).
- Реализация: генерируем presigned URL, пишем queue workers, подключаем WebSocket.
- Тестирование: load test с разными размерами видео, проверка качества thumbnails и аудио.
- Деплой: настраиваем мониторинг (алерты на ошибки транскодинга), CI/CD для обновлений pipeline.
Срок реализации
| Задача | Срок |
|---|---|
| Presigned upload + FFmpeg в очереди | 4–5 дней |
| Thumbnail генерация + метаданные | +1–2 дня |
| AWS MediaConvert интеграция | 2–3 дня |
| HLS adaptive streaming | +3–4 дня (отдельная задача) |
Что входит в работу
- Архитектурная документация pipeline.
- Исходный код с комментариями (Laravel + FFmpeg или Python + MediaConvert).
- Доступы к S3, настройка IAM ролей.
- Скрипты для мониторинга и алертинга.
- Обучение команды поддержке.
Частые ошибки на практике
- Не устанавливать timeout на задачи — транскодинг может длиться часы, а воркер падает.
- Не обрабатывать ошибки загрузки (прерывание интернета, неверный content-type).
- Генерировать presigned URL на слишком короткое время — пользователь не успевает загрузить.
Имеем 5+ лет опыта в построении видео-pipeline, выполнили более 30 проектов по транскодингу для медиа-сайтов и образовательных платформ. Оценим ваш проект за 1 день — пишите. Получите консультацию по архитектуре видео-pipeline. Свяжитесь с нами для получения консультации. Закажите разработку — получите оценку за 1 день.







