Implementing Video Transcoding Pipeline (FFmpeg) on Server
Users upload videos in anything — iPhone MOV, torrent MKV, 2008 AVI. Backend task — accept, deliver to browser normal MP4/H.264 or WebM/VP9, without blocking web process for minutes of transcoding.
Pipeline Architecture
Video transcoding — CPU-intensive operation lasting seconds to tens of minutes depending on video length and encoding profile. Synchronous processing in HTTP request excluded.
Right architecture:
Upload → S3/local storage → Job Queue → Worker → FFmpeg → Output storage → Notify
- User uploads file — save original, return task ID.
- Put Job in queue.
- Worker takes Job, runs FFmpeg, writes progress to Redis.
- After completion update database record, notify via WebSocket or polling.
Server Requirements
FFmpeg must be compiled with needed codecs:
ffmpeg -version
ffmpeg -codecs | grep -E 'h264|hevc|vp9|aac|opus'
For Ubuntu/Debian sufficient package from repository:
apt install ffmpeg
On some hosts (shared) FFmpeg unavailable — need VPS or dedicated server. Minimal recommended config for 1080p transcoding: 4 CPU, 4 GB RAM.
Transcoding Profiles
// 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) — main quality parameter for H.264: 18 = near lossless, 28 = acceptable quality with small size. preset affects encoding speed vs file size.
FFmpeg Service
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 preserving aspect ratio, padding to needed size
$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 at start — critical for streaming
"-progress pipe:1", // progress to 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]);
// Read progress line by line
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 outputs "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');
}
}
Transcoding Job
// app/Jobs/TranscodeVideoJob.php
class TranscodeVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 3600; // 1 hour
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
));
// Update progress no more than once per 5 seconds
Cache::put("video_progress_{$video->id}", $pct, 30);
}
}
);
$video->variants()->create([
'profile' => $this->profile,
'path' => "transcoded/{$outputFile}",
'size' => filesize($outputPath),
]);
// If all profiles ready — change status
$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()}");
}
}
Dispatch Multiple Profiles
// In controller after upload
$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)); // small delay between Jobs
}
Queue transcoding should be processed by separate worker process with limited parallel tasks. For 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 per worker prevents CPU overload with parallel transcoding.
Streaming Optimization
-movflags +faststart — mandatory flag. Moves metadata atom (moov) to start of MP4 file, allowing browser to start playback before full download. Without it, can't start watching until entire file downloads.
Check:
ffprobe -v quiet -print_format json -show_format output.mp4 | grep moov
# or
mp4info output.mp4 | grep "moov"
Timeline
Setup FFmpeg service, profiles, Job with progress — 1 working day (8–10 hours). Endpoint for progress polling, VideoVariant models, Supervisor config — another 4–6 hours. Full pipeline with WebSocket notifications on readiness — additional 3–5 hours.







