Clients upload video in various formats — from MP4 from phones to MOV from professional cameras. If you convert on the fly, the server buckles under load, requests time out, and users get corrupted files. Without proper architecture, video upload becomes a bottleneck for any media site. We develop an async pipeline: presigned upload to the cloud immediately bypasses the server, and transcoding happens in a queue on dedicated workers or managed services.
Typical scenario: a user shoots 4K 60fps (500 MB). Direct server upload causes an nginx timeout. Presigned URL solves this — the file goes to S3 in a couple of minutes, the server only confirms. Then a worker picks up the file and transcodes in the background, not blocking the user. Progress is shown via WebSocket — the user sees status in real time.
Why move transcoding to a background queue?
Transcoding is CPU-intensive. Even a 1080p short video runs the CPU at 100% for minutes. If done in a web process, other requests are blocked. Our pipeline uses S3 Event → SQS/Lambda → workers with FFmpeg. This allows horizontal scaling without affecting site response time. CPU load drops 90% when moved to a queue.
What problems does the video upload and transcoding system solve?
- CPU blocking: processing doesn't affect the web server.
- Multiple resolutions: automagically generate 360p, 720p, 1080p and WebM to cover all devices.
- Slow user upload: presigned link lets them upload directly to the cloud, bypassing the server; progress tracking via WebSocket keeps them informed.
How we build the video upload and transcoding system
We use presigned URLs from AWS S3: the client gets a temporary link and sends the file straight to a bucket. After upload completes, the server writes a DB record and queues a task (e.g., via SQS or Laravel Horizon).
Pipeline architecture
[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]
Step 1: Presigned Upload to S3
// Generate presigned URL for direct upload from client
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');
// Create DB record with pending status
$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,
]);
}
// Client calls after successful upload
public function confirmUpload(Request $request, Video $video): JsonResponse
{
$this->authorize('update', $video);
$video->update(['status' => 'uploaded']);
TranscodeVideoJob::dispatch($video);
return response()->json(['status' => 'processing']);
}
}
Step 2: Transcoding with FFmpeg
class TranscodeVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $timeout = 7200; // 2 hours
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']);
// Download original to temporary file
$tempInput = tempnam(sys_get_temp_dir(), 'video_') . '.mp4';
Storage::disk('s3')->copy($this->video->original_key, $tempInput); // simplified
// Actually: stream from S3 via 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', // balance speed/quality
'-crf', '23',
'-maxrate', $params['bitrate'],
'-bufsize', (int)($params['bitrate']) * 2 . 'k',
'-c:a', 'aac',
'-b:a', $params['audiorate'],
'-movflags', '+faststart', // for 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());
}
// Upload to S3
Storage::disk('s3')->putFileAs(
dirname($outputKey),
new \Illuminate\Http\File($outputPath),
basename($outputKey),
);
$paths[$quality] = $outputKey;
unlink($outputPath);
}
// Generate thumbnail from 10% duration
$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% of duration
'-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),
);
}
// Get metadata
$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(),
]);
// Notify user
$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 processing failed', ['video_id' => $this->video->id, 'error' => $e->getMessage()]);
}
}
Which approach to choose: FFmpeg on your own infrastructure or AWS MediaConvert?
FFmpeg gives full control and no extra AWS costs, but you need to manage servers and queues. MediaConvert is a managed service: you pay per minute of transcoding, no scaling worries. For startups and mid-size projects, FFmpeg in a queue (e.g., Laravel Horizon) is budget-friendly. For large video catalogs with high load, MediaConvert is more cost-effective: it handles spikes automatically and supports HLS. For volumes up to 1000 minutes per month, FFmpeg on your own infrastructure is three times cheaper than MediaConvert, saving you up to $30 per month.
| Criterion | FFmpeg + queue | AWS MediaConvert |
|---|---|---|
| Control | Full control over parameters and codecs | Limited to presets |
| Scaling | Requires autoscaling setup for workers | Automatic scaling |
| Cost for 1000 min/month | $0 (only server) | ~$30 (per tariff) |
| Setup time | 2–3 days | 1 day |
MediaConvert is twice as fast on large volumes due to parallel processing, but FFmpeg is three times cheaper for small volumes.
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 similarly
],
}],
}
)
return job['Job']['Id']
Transcoding progress
// Serve progress via SSE or 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,
]);
});
Process of work
- Analysis: collect video types, expected volume, requirements for adaptive delivery.
- Design: choose S3 vs own storage, configure FFmpeg parameters (bitrate, CRF, presets).
- Implementation: generate presigned URLs, write queue workers, set up WebSocket.
- Testing: load test with different video sizes, verify thumbnail and audio quality.
- Deploy: configure monitoring (alerts on transcoding errors), CI/CD for pipeline updates.
Timeline
| Task | Time |
|---|---|
| Presigned upload + FFmpeg in queue | 4–5 days |
| Thumbnail generation + metadata | +1–2 days |
| AWS MediaConvert integration | 2–3 days |
| HLS adaptive streaming | +3–4 days (separate task) |
What’s included
- Architectural pipeline documentation.
- Source code with comments (Laravel + FFmpeg or Python + MediaConvert).
- S3 access setup, IAM role configuration.
- Monitoring and alerting scripts.
- Team training for support.
- Guaranteed response time for support tickets (SLA).
- Certified integration with major cloud providers (AWS, GCP, Azure).
- 5+ years of experience building video pipelines, with over 30 successful projects for media sites and educational platforms.
Common mistakes in practice: not setting a timeout on jobs — transcoding can take hours and workers crash; not handling upload errors (internet interruption, wrong content-type); presigned URL too short — user doesn’t finish uploading.
We assess your project in one day — just get in touch. Get a consultation on video pipeline architecture. Leave a request to get your estimate within one day.







