Video transcoding pipeline with FFmpeg on server

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Showing 1 of 1 servicesAll 2065 services
Video transcoding pipeline with FFmpeg on server
Complex
~5 business days
FAQ
Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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
  1. User uploads file — save original, return task ID.
  2. Put Job in queue.
  3. Worker takes Job, runs FFmpeg, writes progress to Redis.
  4. 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.