Реализация загрузки и транскодинга видео
Загрузка видео требует отдельного pipeline: файл принимается на сервер или напрямую в облако, затем транскодируется в несколько качеств (360p, 720p, 1080p) и форматов (MP4/H.264, WebM/VP9). Транскодинг — CPU-intensive операция, выполняется в фоне.
Архитектура 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()]);
}
}
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,
]);
});
Срок реализации
| Задача | Срок |
|---|---|
| Presigned upload + FFmpeg в очереди | 4–5 дней |
| Thumbnail генерация + метаданные | +1–2 дня |
| AWS MediaConvert интеграция | 2–3 дня |
| HLS adaptive streaming | +3–4 дня (отдельная задача) |







