Blog
Multimedia29 min czytania

FFmpeg — przetwarzanie wideo i audio w aplikacjach webowych

FFmpeg to 1,5 miliona linii C, przez które przechodzi każdy plik wideo wgrany przez użytkownika. Zbieramy gotowe przepisy na transkodowanie, HLS, miniatury i normalizację dźwięku, integrację z Laravelem przez kolejkę i Process, hartowanie przeciw SSRF przez pliki HLS oraz różnicę między LGPL a GPL, która decyduje o tym, czy wolno Ci ten plik binarny rozdać dalej.

FFmpeg jest tym rodzajem oprogramowania, którego się nie wybiera — po prostu w pewnym momencie okazuje się, że już od dawna go używasz. Dekoder wideo w Chrome i Firefoksie to libavcodec. VLC, OBS, HandBrake, Kodi, Jellyfin i Plex stoją na tym samym kodzie. Kiedy w aplikacji webowej pojawia się pierwszy formularz z polem „wgraj wideo”, pytanie nie brzmi, czy sięgniesz po FFmpeg, tylko jak szybko się zorientujesz, że nie ma dla niego alternatywy.

Problem w tym, że FFmpeg jest narzędziem dla ludzi, którzy zajmują się wideo zawodowo, a używają go głównie ci, którzy zajmują się czymś innym. Dokumentacja opisuje kilkaset filtrów i kilka tysięcy opcji, nie mówiąc ani słowa o tym, które dwadzieścia z nich potrzebujesz, żeby wgrany przez klienta plik z telefonu odtworzył się w przeglądarce na iPhonie. Ten wpis jest próbą zamknięcia tej luki: co realnie trzeba wiedzieć, żeby wpiąć FFmpeg w aplikację Laravelowa i nie zrobić sobie przy okazji krzywdy.

Stan projektu, sierpień 2026

Kod mieszka na git.ffmpeg.org, a github.com/FFmpeg/FFmpeg to lustro. To rozróżnienie ma praktyczne konsekwencje: pull requesty na GitHubie nie są przyjmowane, zgłoszenia błędów idą na trac.ffmpeg.org, a łatki wysyła się mailem na listę ffmpeg-devel. Trzy otwarte issues na lustrze o niczym nie świadczą — prawdziwy tracker ma ich kilka tysięcy.

Liczby: 63 490 gwiazdek, 14 174 forki, ponad 2700 współtwórców w historii repozytorium, napisane w C z ręcznie pisanym asemblerem w gorących ścieżkach. Projekt wystartował w 2000 roku (Fabrice Bellard), lustro na GitHubie działa od 2011. Ostatni commit w chwili pisania tego tekstu: dzisiaj rano.

Wydania wychodzą mniej więcej co pół roku. Aktualna linia to FFmpeg 9.0 „Lei” z 4 sierpnia 2026, z wydaniem poprawkowym 9.0.1 z 12 sierpnia. Nazwa upamiętnia Lei Xiaohua, doktoranta Communication University of China, którego notatki o FFmpeg były przez lata podstawowym materiałem dla chińskojęzycznych deweloperów multimediów; 2026 to dziesiąta rocznica jego śmierci.

Równolegle utrzymywanych jest siedem gałęzi — to nietypowo dużo jak na projekt tej wielkości i warto to wiedzieć, zanim zaczniesz planować aktualizację:

  • 9.0.1 — 12 sierpnia 2026, gałąź odcięta 26 czerwca 2026,
  • 8.1.2 — 17 czerwca 2026,
  • 8.0.3 — 18 czerwca 2026,
  • 7.1.5 — 20 czerwca 2026,
  • 6.1.6 — 20 czerwca 2026,
  • 5.1.10 i 4.4.8 — 21 czerwca 2026.

Gałąź 4.4 została odcięta w kwietniu 2021 i nadal dostaje poprawki bezpieczeństwa. Dla kogoś, kto ma w produkcji obraz sprzed czterech lat, to dobra wiadomość — ale, jak zobaczymy w sekcji o bezpieczeństwie, nie aż tak dobra, żeby nie aktualizować.

Z czego to się składa

FFmpeg to zestaw bibliotek plus trzy narzędzia wiersza poleceń. Biblioteki:

  • libavcodec — kodeki, czyli sama kompresja i dekompresja,
  • libavformat — kontenery (MP4, MKV, WebM) i protokoły strumieniowe,
  • libavfilter — filtry wideo i audio, od skalowania po nakładki,
  • libavutil — funkcje pomocnicze wspólne dla reszty,
  • libavdevice — dostęp do urządzeń (kamera, ekran, karta dźwiękowa),
  • libswscale — skalowanie i konwersja przestrzeni barw,
  • libswresample — resampling i miksowanie dźwięku.

Narzędzia: ffmpeg (konwersja i przetwarzanie), ffprobe (analiza pliku) i ffplay (odtwarzacz, w praktyce przydatny tylko przy debugowaniu). Historycznego ffserver nie ma od wersji 4.0 — jeśli trafisz na poradnik, który go używa, poradnik ma co najmniej osiem lat i prawdopodobnie zawiera też inne nieaktualne rzeczy.

W aplikacji webowej niemal zawsze wołasz ffmpeg i ffprobe jako procesy potomne, a nie linkujesz się z bibliotekami. To ma znaczenie licencyjne i wrócimy do tego na końcu, bo różnica między uruchamianiem binarki a łączeniem z biblioteką jest dokładnie tą różnicą, o którą chodzi w LGPL.

Najpierw ffprobe, potem cokolwiek innego

Pierwsza reguła pracy z plikami od użytkowników: nie ufaj ani rozszerzeniu, ani nagłówkowi MIME. Jedno i drugie pochodzi od klienta i jedno i drugie da się ustawić dowolnie. Jedyne wiarygodne źródło informacji o pliku to jego zawartość, a odczytuje ją ffprobe.

ffprobe -v error -print_format json \
    -show_format -show_streams \
    upload.mp4

Dostajesz JSON z listą strumieni i metadanymi kontenera. Interesują Cię zwykle cztery rzeczy: czy w ogóle jest strumień wideo, jaki ma kodek, jakie wymiary i jak długi jest materiał. W Laravelu wygląda to tak:

use Illuminate\Support\Facades\Process;

$result = Process::timeout(30)->run([
    'ffprobe',
    '-v', 'error',
    '-print_format', 'json',
    '-show_format',
    '-show_streams',
    $absolutePath,
]);

if (! $result->successful()) {
    throw new InvalidUploadException('Plik nie jest czytelnym materiałem wideo.');
}

$probe = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);

$video = collect($probe['streams'])
    ->firstWhere('codec_type', 'video');

if ($video === null) {
    throw new InvalidUploadException('Brak strumienia wideo.');
}

$duration = (float) ($probe['format']['duration'] ?? 0);

Zwróć uwagę na tablicową postać argumentów. Process::run() z tablicą nie uruchamia powłoki, więc nazwa pliku w rodzaju ; rm -rf / jest po prostu nazwą pliku. Przy postaci ze stringiem musiałbyś pamiętać o escapeshellarg() przy każdym argumencie — i któregoś dnia byś nie pamiętał.

Dwie pułapki, które zjadają czas przy pierwszym wdrożeniu:

  • format.duration bywa puste. W kontenerach strumieniowych (MPEG-TS, część plików z kamer IP) długość nie jest zapisana w nagłówku. Możesz ją policzyć przez -count_frames, ale to oznacza przeczytanie całego pliku — przy godzinnym materiale to nie jest operacja na 30 sekund timeoutu. Alternatywa: policz długość dopiero po transkodowaniu, kiedy i tak przechodzisz przez cały materiał.
  • Plik z jednym strumieniem wideo o kodeku mjpeg to zwykle okładka, nie wideo. MP3 z osadzoną grafiką albo MP4 z miniaturą wygląda w ffprobe jak materiał wideo o długości jednej klatki. Sprawdzaj disposition.attached_pic, zanim uznasz, że dostałeś film.

Przepisy, których realnie potrzebujesz

MP4, który odtworzy się wszędzie

Kanoniczny profil wyjściowy dla webu to H.264 w profilu high, dźwięk AAC, przestrzeń barw yuv420p i atom moov przesunięty na początek pliku:

ffmpeg -hide_banner -nostdin -y \
    -i input.mov \
    -c:v libx264 -preset medium -crf 23 \
    -profile:v high -level 4.0 -pix_fmt yuv420p \
    -c:a aac -b:a 128k -ac 2 -ar 48000 \
    -movflags +faststart \
    output.mp4

Każda z tych opcji rozwiązuje konkretny problem:

  • -pix_fmt yuv420p — bez tego materiał z kamery zapisany w 4:2:2 albo 4:4:4 zakoduje się poprawnie i nie odtworzy w Safari ani w QuickTimie. To najczęstsza przyczyna zgłoszenia „u mnie działa, u klienta czarny ekran”.
  • -movflags +faststart — przenosi indeks pliku (moov) z końca na początek. Bez tego przeglądarka musi pobrać cały plik, zanim zacznie odtwarzanie. Kosztuje jedno dodatkowe przepisanie pliku po zakończeniu kodowania, więc przy dużych materiałach potrzebujesz miejsca na dysku.
  • -crf 23 — stała jakość zamiast stałego bitrate'u. Skala 0–51, domyślnie 23, niżej znaczy lepiej i większy plik. Praktyczny zakres to 18–28; różnica między 23 a 20 jest widoczna, między 20 a 18 — rzadko.
  • -preset medium — kompromis czas/rozmiar. Od ultrafast do placebo. Zmiana z medium na slow daje kilka procent mniejszy plik za mniej więcej dwukrotnie dłuższe kodowanie; z medium na veryfast — odwrotnie.
  • -nostdin — pozornie kosmetyczne, w praktyce niezbędne w workerach kolejki. FFmpeg domyślnie czyta standardowe wejście w poszukiwaniu poleceń interaktywnych i potrafi przez to zawiesić proces nadrzędny albo zjeść dane, których ten proces oczekiwał.

Skalowanie bez błędu parzystości

H.264 w yuv420p wymaga parzystych wymiarów. Jeśli przeskalujesz materiał 1080×607 do szerokości 640, wysokość wyjdzie 359 i kodowanie padnie komunikatem width not divisible by 2. Rozwiązanie to -2 zamiast -1:

ffmpeg -i input.mp4 -vf "scale=1280:-2" -c:a copy output.mp4

-2 znaczy „dobierz zachowując proporcje i zaokrąglij do parzystej”. Jeżeli chcesz jednocześnie nie powiększać materiałów mniejszych niż docelowe:

-vf "scale='min(1280,iw)':-2"

Miniatury i kontaktówka

Pojedyncza klatka z konkretnej sekundy:

ffmpeg -ss 00:00:05 -i input.mp4 -frames:v 1 -q:v 2 thumb.jpg

Kolejność ma znaczenie. -ss przed -i to szybkie przewijanie — FFmpeg skacze bezpośrednio w okolice podanego czasu. -ss po -i dekoduje wszystko od początku i przy godzinnym pliku różnica to sekundy kontra minuty.

Jeśli nie chcesz zgadywać, która sekunda jest reprezentatywna, filtr thumbnail wybierze klatkę o największej „ciekawości” z okna:

ffmpeg -i input.mp4 -vf "thumbnail=300,scale=640:-2" -frames:v 1 thumb.jpg

Sprite z podglądami do paska postępu odtwarzacza (siatka 10×10 co dziesięć sekund) to jedno polecenie:

ffmpeg -i input.mp4 \
    -vf "fps=1/10,scale=160:-2,tile=10x10" \
    -frames:v 1 -q:v 4 \
    sprite.jpg

Plik WebVTT z koordynatami wycinków musisz wygenerować sam po stronie PHP — to zwykła pętla po numerach klatek, przeliczająca indeks na #xywh=.

HLS z kilkoma jakościami

Adaptacyjny bitrate to jedyny sensowny sposób podawania wideo do przeglądarki, jeśli nie wiesz, jakim łączem dysponuje odbiorca. Jedno polecenie generuje trzy warianty i playlistę nadrzędną:

ffmpeg -hide_banner -nostdin -y -i input.mp4 \
    -filter_complex "[0:v]split=3[v1][v2][v3]; \
        [v1]scale=w=640:h=-2[v1out]; \
        [v2]scale=w=1280:h=-2[v2out]; \
        [v3]scale=w=1920:h=-2[v3out]" \
    -map "[v1out]" -c:v:0 libx264 -b:v:0 800k  -maxrate:v:0 856k  -bufsize:v:0 1200k \
    -map "[v2out]" -c:v:1 libx264 -b:v:1 2800k -maxrate:v:1 2996k -bufsize:v:1 4200k \
    -map "[v3out]" -c:v:2 libx264 -b:v:2 5000k -maxrate:v:2 5350k -bufsize:v:2 7500k \
    -map a:0 -map a:0 -map a:0 -c:a aac -b:a 128k -ac 2 \
    -preset veryfast -g 48 -keyint_min 48 -sc_threshold 0 \
    -f hls -hls_time 4 -hls_playlist_type vod \
    -hls_segment_filename "stream_%v/seg_%03d.ts" \
    -master_pl_name master.m3u8 \
    -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
    stream_%v/playlist.m3u8

Trzy rzeczy, które łatwo tu przeoczyć:

  • -g 48 -keyint_min 48 -sc_threshold 0 nie jest ozdobnikiem. Segmenty HLS muszą zaczynać się od klatki kluczowej, a x264 domyślnie wstawia je przy zmianie sceny. Bez wymuszenia stałego odstępu warianty rozjadą się w czasie i przełączenie jakości w trakcie odtwarzania spowoduje przeskok albo zacięcie. Wartość -g to długość segmentu razy liczba klatek na sekundę — przy -hls_time 4 i 24 kl./s wychodzi 96, przy 12 kl./s 48. Powyższa konfiguracja zakłada 12 kl./s; dostosuj ją do materiału.
  • -maxrate i -bufsize bez -b:v nic nie robią, a bez nich -b:v jest tylko sugestią. Reguła kciuka: maxrate około 107% docelowego bitrate'u, bufsize półtora raza maxrate.
  • Ścieżka audio jest mapowana trzy razy. Każdy wariant potrzebuje własnej kopii, nawet jeśli parametry są identyczne.

Normalizacja głośności

Materiały wgrywane przez użytkowników różnią się głośnością o kilkanaście decybeli. Filtr loudnorm implementuje standard EBU R128 i działa poprawnie tylko w dwóch przebiegach. Pierwszy mierzy:

ffmpeg -i input.mp4 -af loudnorm=I=-16:LRA=11:TP=-1.5:print_format=json -f null -

Z wypisanego JSON-a bierzesz input_i, input_lra, input_tp, input_thresh i target_offset, a potem karmisz nimi drugi przebieg:

ffmpeg -i input.mp4 \
    -af loudnorm=I=-16:LRA=11:TP=-1.5:measured_I=-27.2:measured_LRA=9.4:measured_TP=-8.1:measured_thresh=-38.1:offset=0.4:linear=true \
    -c:v copy -c:a aac -b:a 192k output.mp4

Jednoprzebiegowy loudnorm też działa, ale kompresuje dynamikę adaptacyjnie i przy materiale z ciszą na początku potrafi „pompować” głośność. Cel -16 LUFS to praktyka platform streamingowych; nadawcy telewizyjni w Europie stosują -23 LUFS zgodnie z R128. Jeśli robisz podcast albo wideo na stronę, -16 jest bezpieczniejsze, bo wpisuje się w to, do czego przyzwyczajone są uszy odbiorcy.

Animowany podgląd zamiast GIF-a

GIF wymaga wygenerowania palety, inaczej wygląda źle:

ffmpeg -i input.mp4 -vf "fps=12,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff" -y palette.png

ffmpeg -i input.mp4 -i palette.png \
    -lavfi "fps=12,scale=480:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
    -y preview.gif

Animowany WebP jest zwykle kilkukrotnie mniejszy przy lepszej jakości i od dawna ma wsparcie we wszystkich aktualnych przeglądarkach:

ffmpeg -i input.mp4 \
    -vf "fps=15,scale=480:-2" \
    -c:v libwebp -lossless 0 -q:v 60 -loop 0 -preset picture \
    -an -fps_mode passthrough \
    preview.webp

Uwaga na -vsync 0 z dawnych poradników — opcja jest przestarzała i w nowszych wydaniach wypisuje ostrzeżenie. Odpowiednikiem jest -fps_mode passthrough.

Znak wodny

ffmpeg -i input.mp4 -i logo.png \
    -filter_complex "[1:v]scale=120:-1[wm];[0:v][wm]overlay=W-w-24:H-h-24:format=auto" \
    -c:a copy output.mp4

W i H to wymiary materiału głównego, w i h — nakładki. Powyższe umieszcza logo 24 piksele od prawej dolnej krawędzi.

Cięcie bez rekodowania i dlaczego bywa nieprecyzyjne

ffmpeg -ss 00:01:30 -i input.mp4 -t 30 -c copy output.mp4

To działa w ułamku sekundy, bo strumienie są przepisywane bez dekodowania. Cena: cięcie może nastąpić tylko na klatce kluczowej. Przy typowym odstępie dwóch–czterech sekund fragment zacznie się do czterech sekund wcześniej, niż prosiłeś, albo pierwsze klatki będą zniekształcone. Jeśli potrzebujesz dokładności co do klatki, nie ma wyjścia poza rekodowaniem:

ffmpeg -ss 00:01:30 -i input.mp4 -t 30 \
    -c:v libx264 -crf 20 -preset medium \
    -c:a aac -b:a 128k \
    -avoid_negative_ts make_zero \
    output.mp4

Sklejanie plików

Demuxer concat łączy pliki o identycznych parametrach bez rekodowania. Wymaga pliku z listą:

# list.txt
file '/var/www/storage/app/clips/a.mp4'
file '/var/www/storage/app/clips/b.mp4'
ffmpeg -f concat -safe 0 -protocol_whitelist file -i list.txt -c copy output.mp4

-safe 0 zezwala na ścieżki bezwzględne i jest tu konieczne, ale otwiera dokładnie ten wektor ataku, o którym za chwilę — nigdy nie buduj tego pliku z danych pochodzących od użytkownika. Jeżeli parametry strumieni się różnią (inna rozdzielczość, inny kodek, inna częstotliwość próbkowania), -c copy wyprodukuje plik, który częściowo się nie odtwarza. Wtedy potrzebujesz filtra concat z rekodowaniem.

Wydajność i koszt

Transkodowanie to najdroższa operacja, jaką typowa aplikacja webowa w ogóle wykonuje. Kilka rzeczy, które realnie zmieniają rachunek:

CRF zamiast bitrate'u. Stały bitrate marnuje pasmo na statycznych ujęciach i psuje jakość w scenach z ruchem. CRF daje przewidywalną jakość i nieprzewidywalny rozmiar — dla wideo na stronie to lepszy kompromis. Bitrate ma sens tylko tam, gdzie rozmiar jest twardym ograniczeniem, czyli głównie przy HLS.

-threads i kolejka. FFmpeg domyślnie zajmuje wszystkie rdzenie. Jeżeli worker kolejki obsługuje pięć zadań równolegle na ośmiordzeniowej maszynie, dostajesz czterdzieści wątków walczących o osiem rdzeni i wszystko zwalnia. Rozsądniej ograniczyć -threads do dwóch–czterech i pozwolić kolejce robić równoległość. Pakiet pbmedia/laravel-ffmpeg ustawia domyślnie threads => 12, co przy wielu równoległych zadaniach jest wartością do zmiany, nie do zostawienia.

Akceleracja sprzętowa. NVENC (NVIDIA), VAAPI (Intel/AMD na Linuksie), QSV (Intel Quick Sync) i VideoToolbox (Apple) potrafią przyspieszyć kodowanie kilkunastokrotnie. Kompromis jest jednak realny i warto go znać przed decyzją: przy tym samym bitrate koder sprzętowy daje zauważalnie gorszy obraz niż x264 na presecie medium. Sprzęt jest właściwym wyborem przy transkodowaniu na żywo albo przy dużym wolumenie, gdzie liczy się przepustowość. Przy pojedynczych plikach wgrywanych okazjonalnie x264 na CPU zwykle wygrywa jakością na megabajt.

Jeżeli używasz NVENC-a, wydanie 9.0 wymaga uwagi: usunięto z niego przestarzałe opcje kodera i wsparcie dla SDK starszych niż 11.1. Skrypty pisane pod FFmpeg 5 albo 6 mogą przestać się uruchamiać z komunikatem o nieznanej opcji.

Wpięcie w Laravela

Zawsze przez kolejkę

To nie jest kwestia stylu. Transkodowanie dziesięciominutowego materiału zajmuje minuty, a każdy serwer HTTP przed aplikacją ma timeout liczony w sekundach. Wideo idzie do kolejki, użytkownik dostaje odpowiedź natychmiast, a status przetwarzania trzymasz w bazie.

use Illuminate\Support\Facades\Process;

class TranscodeUploadedVideo implements ShouldQueue
{
    use Queueable;

    public int $timeout = 3600;

    public int $tries = 2;

    public function __construct(public Video $video) {}

    public function handle(): void
    {
        $source = Storage::disk('uploads')->path($this->video->original_path);
        $target = Storage::disk('videos')->path("{$this->video->uuid}.mp4");

        $result = Process::timeout(3540)->run([
            'ffmpeg', '-hide_banner', '-nostdin', '-y',
            '-protocol_whitelist', 'file',
            '-i', $source,
            '-vf', 'scale=min(1920\,iw):-2',
            '-c:v', 'libx264', '-preset', 'medium', '-crf', '23',
            '-pix_fmt', 'yuv420p',
            '-c:a', 'aac', '-b:a', '128k',
            '-movflags', '+faststart',
            '-threads', '4',
            $target,
        ]);

        if (! $result->successful()) {
            throw new TranscodingFailedException($result->errorOutput());
        }

        $this->video->update(['status' => VideoStatus::Ready]);
    }
}

Timeout zadania musi być dłuższy niż timeout procesu. W przykładzie zadanie ma godzinę, proces 59 minut. Odwrotna kolejność daje najgorszy możliwy scenariusz: kolejka zabija zadanie, proces ffmpeg zostaje osierocony i dalej mieli CPU, a przy tries = 2 obok niego startuje drugi taki sam.

Postęp w czasie rzeczywistym

FFmpeg potrafi wypisywać postęp w formacie nadającym się do parsowania, zamiast miganego paska dla człowieka. Opcja -progress pipe:1 -nostats wysyła na standardowe wyjście bloki klucz=wartość:

frame=1234
fps=48.2
out_time_us=41400000
out_time=00:00:41.400000
progress=continue

Wystarczy podpiąć domknięcie do Process::run():

Process::timeout(3540)->run($command, function (string $type, string $chunk) use ($duration) {
    foreach (explode("\n", $chunk) as $line) {
        if (! str_starts_with($line, 'out_time_us=')) {
            continue;
        }

        $seconds = ((int) substr($line, 12)) / 1_000_000;

        if ($duration > 0) {
            $this->video->update([
                'progress' => min(100, (int) round($seconds / $duration * 100)),
            ]);
        }
    }
});

Aktualizowanie rekordu przy każdym bloku to kilkadziesiąt zapisów na sekundę — w praktyce warto dławić to do zmiany co pełny procent albo co dwie sekundy. Wartość out_time_us bywa też N/A na samym początku, co rzutowanie na int zamienia w zero i to akurat jest zachowanie, którego chcemy.

Kiedy sięgnąć po pakiet

protonemedia/laravel-ffmpeg (Composer: pbmedia/laravel-ffmpeg) opakowuje bibliotekę php-ffmpeg/php-ffmpeg i wpina ją w warstwę dysków Laravela. Wersja 8.9.0 z lutego 2026 wymaga PHP 8.2–8.5 i illuminate/contracts 11, 12 albo 13, więc na aktualnym Laravelu wchodzi bez tarcia.

Najmocniejszą stroną pakietu jest to, czego nie chce się pisać samemu: pełny eksport HLS, także szyfrowany AES-128 z rotacją kluczy.

use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg;
use ProtoneMedia\LaravelFFMpeg\Exporters\HLSExporter;
use FFMpeg\Format\Video\X264;

$key = HLSExporter::generateEncryptionKey();

FFMpeg::fromDisk('uploads')
    ->open('lekcja.mp4')
    ->exportForHLS()
    ->withRotatingEncryptionKey(function (string $filename, string $contents) {
        Storage::disk('hls-keys')->put($filename, $contents);
    }, 10)
    ->setSegmentLength(4)
    ->setKeyFrameInterval(48)
    ->addFormat((new X264)->setKiloBitrate(800))
    ->addFormat((new X264)->setKiloBitrate(2800))
    ->addFormat((new X264)->setKiloBitrate(5000))
    ->toDisk('videos')
    ->save('lekcja/master.m3u8');

Rotacja klucza co dziesięć segmentów oznacza, że wyciek jednego klucza odsłania czterdzieści sekund materiału, a nie całą lekcję. Do tego dochodzą eksport klatek, kontaktówki, znaki wodne (z spatie/image do obróbki samego logo), pliki VTT z podglądami i onProgress().

Konfiguracja jest krótka i warto ją przejrzeć od razu po instalacji:

return [
    'ffmpeg' => [
        'binaries' => env('FFMPEG_BINARIES', 'ffmpeg'),
        'threads'  => 12,   // false wyłącza domyślny filtr 'threads'
    ],
    'ffprobe' => [
        'binaries' => env('FFPROBE_BINARIES', 'ffprobe'),
    ],
    'timeout'     => 3600,
    'log_channel' => env('LOG_CHANNEL', 'stack'),
    'temporary_files_root' => env('FFMPEG_TEMPORARY_FILES_ROOT', sys_get_temp_dir()),
];

Dwie rzeczy do zmiany zaraz po publikacji konfiguracji: threads (patrz wyżej) i temporary_files_root. Pakiet ściąga pliki z dysków zdalnych do katalogu tymczasowego, żeby FFmpeg mógł na nich pracować. Domyślny sys_get_temp_dir() w kontenerze to zwykle /tmp na warstwie nadpisywalnej — przy godzinnym materiale 4K zapełnisz ją bez ostrzeżenia. I pamiętaj o FFMpeg::cleanupTemporaryFiles(), bo pliki tymczasowe nie znikają same.

Kiedy pakiet, a kiedy goły Process? Pakiet wygrywa przy HLS, pracy na dyskach zdalnych i tam, gdzie zależy Ci na czytelnym API dla całego zespołu. Process wygrywa wszędzie tam, gdzie potrzebujesz konkretnego zestawu flag — filtry sprzętowe, nietypowe mapowania strumieni, świeże opcje z wydania 9.0. Warstwa abstrakcji nad narzędziem o kilku tysiącach opcji zawsze będzie odsłaniać ich podzbiór, a schodzenie do ->addFilter() z surowymi argumentami odbiera pakietowi jego główną zaletę. Mieszanie obu podejść w jednym projekcie jest zupełnie w porządku.

Bezpieczeństwo: to jest parser nad danymi od obcych

Ta sekcja jest ważniejsza niż wszystkie przepisy powyżej. FFmpeg to około półtora miliona linii C, obsługującego kilkaset formatów, z których większość powstała w latach dziewięćdziesiątych i nie ma dziś ani jednego użytkownika. Kiedy podajesz mu plik od anonimowego użytkownika internetu, wystawiasz cały ten kod na wejście kontrolowane przez atakującego.

SSRF i odczyt plików przez playlistę

Najbardziej podstępny wektor nie wymaga w ogóle żadnego błędu w kodzie — wystarczy funkcja działająca zgodnie z dokumentacją. FFmpeg obsługuje kilkadziesiąt protokołów wejściowych: file, http, https, concat, hls, gopher, rtmp, data i dalej. Plik playlisty HLS albo listy concat to zwykły tekst, który wskazuje inne źródła. Wgrany przez atakującego plik .m3u8 zawierający wpis file:///etc/passwd albo http://169.254.169.254/latest/meta-data/ zostanie posłusznie pobrany przez Twój serwer, a wynik wyląduje w wygenerowanym materiale wideo — gotowym do pobrania.

To znana klasa podatności, opisana po raz pierwszy przy FFmpeg 2.x, i wciąż działająca wszędzie tam, gdzie nikt jej świadomie nie zablokował. Obrona jest jednolinijkowa:

ffmpeg -protocol_whitelist file -i upload.mp4 ...
Każde wywołanie ffmpeg i ffprobe na pliku pochodzącym od użytkownika musi mieć -protocol_whitelist ograniczony do tego, czego naprawdę potrzebuje — w praktyce prawie zawsze samego file. Domyślnie dozwolone są wszystkie protokoły.

Jeżeli świadomie przetwarzasz HLS-a ze źródła zewnętrznego, whitelista musi obejmować file,crypto,data,http,https,tcp,tls — i wtedy potrzebujesz osobnej warstwy: walidacji domeny źródłowej i blokady adresów prywatnych, dokładnie tak samo jak przy każdym innym miejscu w aplikacji, które pobiera URL podany przez użytkownika.

Historia z 2025 i 2026 roku, którą warto znać

W październiku 2025 agent AI Google'a, Big Sleep, zgłosił opiekunom FFmpeg błąd w dekoderze kodeka LucasArts Smush — dotyczący pierwszych kilkunastu klatek gry Rebel Assault II z 1995 roku. Zgłoszeniu nie towarzyszyła łatka. Opiekunowie zareagowali publicznie i ostro, nazywając to zjawisko „CVE slop” i pytając, czy firma warta bilion dolarów powinna generować pracę dla wolontariuszy, nie dokładając nic poza raportem. Big Sleep znalazł w sumie trzynaście podatności, z których jedna dostała identyfikator CVE-2025-59734 (użycie po zwolnieniu).

W czerwcu 2026 poszło to dalej: firma depthfirst ogłosiła, że jej autonomiczny agent przeanalizował około 1,5 miliona linii kodu C i znalazł 21 nieznanych wcześniej podatności zero-day — za koszt obliczeniowy szacowany na około tysiąc dolarów. Dziewięć z nich ma już numery, od CVE-2026-39210 do CVE-2026-39218.

Dla nas, jako użytkowników, płyną z tego dwa praktyczne wnioski. Po pierwsze: koszt znalezienia błędu w FFmpeg właśnie spadł o kilka rzędów wielkości, a to działa w obie strony — narzędzia dostępne badaczom są dostępne również tym, którzy nie zgłoszą znaleziska opiekunom. Po drugie: tempo napływu poprawek bezpieczeństwa będzie rosło, więc obraz z FFmpeg zamrożony na wersji sprzed dwóch lat przestaje być neutralną decyzją operacyjną.

Jak to hartować

  • Whitelist protokołów przy każdym wywołaniu na danych od użytkownika. Punkt wyżej.
  • Nigdy przez powłokę. Process::run() z tablicą argumentów, nie ze stringiem. Żadnej interpolacji nazw plików do polecenia.
  • Nazwy plików generuj sam. UUID plus rozszerzenie wyznaczone z tego, co zwrócił ffprobe — nigdy nazwa oryginalna, nawet po basename().
  • Izolacja procesu. Transkodowanie w osobnym kontenerze, bez dostępu do sieci (--network none), z limitem pamięci i CPU, bez wolumenów poza katalogiem roboczym. Jeśli FFmpeg wykona kod, ma wtedy do dyspozycji pusty kontener zamiast Twojej aplikacji.
  • Twarde limity. Timeout procesu, -timelimit po stronie FFmpeg jako drugi bezpiecznik, limit rozmiaru wejścia i limit rozdzielczości. Bomba dekompresyjna w postaci pliku o rozdzielczości 65535×65535 to klasyk gatunku.
  • Aktualny build. Gałąź, która nadal dostaje poprawki, i realny proces ich zaciągania. Siedem utrzymywanych gałęzi oznacza, że nie musisz iść na 9.0 od razu — ale oznacza też, że nie masz wymówki, żeby siedzieć na builzie bez wsparcia.

Docker: skąd wziąć aktualne binarium

Obraz PHP nie zawiera FFmpeg i najprostsze apt-get install ffmpeg daje wersję znacznie starszą, niż większość ludzi zakłada. Konkretnie, w chwili pisania tego tekstu:

  • Ubuntu 24.04 LTS (baza obrazów Laravel Sail) — 6.1.1, czyli trzy wydania główne za aktualnym,
  • Debian 13 „trixie” — 7.1.5,
  • Debian 12 „bookworm” — 5.1.9,
  • Ubuntu 25.04 — 7.1.1.

Dla większości zastosowań 6.1 wystarcza w zupełności i nie ma sensu z tym walczyć. Ale jeśli potrzebujesz filtra Whisper, kodowania AV1 przez Vulkana albo natywnego dekodera animowanego WebP, musisz wziąć binarium skądinąd. Najwygodniejsza droga to skopiowanie statycznej binarki z gotowego obrazu:

FROM php:8.5-cli-trixie

COPY --from=mwader/static-ffmpeg:9.0 /ffmpeg  /usr/local/bin/ffmpeg
COPY --from=mwader/static-ffmpeg:9.0 /ffprobe /usr/local/bin/ffprobe

Dwie warstwy COPY i masz aktualną wersję bez kompilowania czegokolwiek i bez ciągnięcia kilkudziesięciu bibliotek do obrazu aplikacji. Alternatywy: jrottenberg/ffmpeg (Apache 2.0, obrazy z akceleracją NVIDIA i VAAPI) oraz linuxserver/ffmpeg. Wszystkie trzy są aktywnie utrzymywane.

Jedna rzecz, o której warto wiedzieć przy mwader/static-ffmpeg: obraz nie zawiera libfdk-aac, właśnie z powodu licencji (trzeba go dobudować własnoręcznie przez ENABLE_FDKAAC). Natywny koder aac we FFmpeg jest od kilku lat wystarczająco dobry przy 128 kb/s i wyżej, więc w praktyce nie jest to problem — a bez fdk-aac binarka pozostaje redystrybuowalna, co jest istotniejsze.

Whisper: napisy jednym poleceniem

FFmpeg 8.0 dołożył filtr whisper, wbudowujący implementację whisper.cpp bezpośrednio w łańcuch filtrów audio. Rozpoznawanie mowy działa lokalnie, bez wysyłania czegokolwiek do zewnętrznego API:

ffmpeg -i wyklad.mp4 -vn \
    -af "whisper=model=/models/ggml-base.bin:language=pl:queue=3:destination=wyklad.srt:format=srt" \
    -f null -

Parametry, które mają znaczenie: model to ścieżka do pobranego modelu whisper.cpp (obowiązkowa), language przyjmuje też auto, a queue określa, ile sekund dźwięku buforować przed każdym przebiegiem rozpoznawania — im więcej, tym lepszy kontekst i wolniejsza reakcja. format obsługuje text, srt i json, a destination=- kieruje wynik na standardowe wyjście, co w kontekście PHP jest wygodniejsze niż plik pośredni. Domyślnie filtr korzysta z GPU; wyłącza się to przez use_gpu=false.

Dodatkowo filtr zapisuje rozpoznany tekst w metadanych klatki jako lavfi.whisper.text, skąd może go odczytać filtr drawtext — czyli napisy wypalone w obrazie także są jednym poleceniem.

Dla aplikacji, które dziś płacą za transkrypcję po API, to zmiana rachunku: koszt schodzi z opłaty za minutę do czasu CPU albo GPU na własnym sprzęcie. Jakość modelu base dla polskiego jest przeciętna, ale modele small i medium nadają się do wygenerowania napisów, które człowiek tylko poprawi.

Co przynosi wersja 9.0

Z perspektywy aplikacji webowej najistotniejsze punkty z listy zmian:

  • Natywny dekoder i demuxer animowanego WebP — bez zależności od libwebp. Konwersja animowanego WebP do wideo działa wreszcie w podstawowym buildzie.
  • Backend DNN na ONNX Runtime z obsługą GPU — modele do skalowania rozdzielczości, upscalingu i segmentacji tła wykonują się w całości na karcie. To otwiera drogę do filtrów AI wewnątrz łańcucha FFmpeg, bez procesu pośredniego w Pythonie.
  • Rozszerzona akceleracja sprzętowa — filtr v360_vulkan do materiałów 360 stopni, transpose_cuda, akceleracja APV przez Vulkana, dekodowanie ProRes RAW przez VideoToolbox na sprzęcie Apple oraz nowe filtry AMD AMF (konwerter klatek, poprawa jakości, rozszerzone HDR).
  • Muxowanie LCEVC w MP4 i przepuszczanie metadanych SMPTE 2094-50 — istotne, jeśli dotykasz HDR.
  • Usunięcia: dekodowanie CELT (nie dotyczy Opusa) i parsowanie ogg/celt, a także wspomniane wcześniej przestarzałe opcje NVENC.

Wszystkie główne biblioteki dostały nowe wersje główne — libavcodec i libavformat wchodzą w serię 63. Jeżeli linkujesz się z bibliotekami (przez FFI albo natywne rozszerzenie), to jest zmiana łamiąca ABI i wymaga przekompilowania. Jeśli tylko wołasz CLI, nie zauważysz.

Wcześniejsze wydanie 8.1 „Hoare” z marca 2026 przyniosło kodowanie H.264 i AV1 przez D3D12, parsowanie metadanych EXIF, kodowanie i dekodowanie ProRes przez Vulkana, filtr drawvg oparty na libcairo, JPEG-XS oraz — warte odnotowania — usunięcie starego handlera protokołu HLS. Osiem zero przyniosło z kolei domyślną weryfikację certyfikatów TLS, porzucenie yasm na rzecz nasm i koniec wsparcia dla OpenSSL starszego niż 1.1.1.

Licencja: LGPL, GPL i cienka granica między nimi

To sekcja, w której najłatwiej zrobić kosztowny błąd, bo licencja FFmpeg nie jest jedna — zależy od tego, jak zbudowano konkretną binarkę, którą masz.

Rdzeń jest na LGPL 2.1 lub nowszej. Część opcjonalnych komponentów i optymalizacji jest na GPL 2 lub nowszej, a włączenie ich flagą --enable-gpl przenosi całość na GPL. Kluczowa konsekwencja praktyczna: libx264 i libx265 są na GPL. Praktycznie każdy build zdolny kodować H.264 przez x264 — czyli ten, którego używasz — jest buildem GPL.

Trzecia kategoria to --enable-nonfree, obejmująca między innymi libfdk-aac. Powstałej binarki nie wolno redystrybuować w ogóle, ani na GPL, ani na żadnych innych warunkach. Wolno jej używać u siebie i to tyle.

Co to znaczy dla typowego projektu

SaaS i aplikacja webowa: praktycznie nic. Uruchamianie ffmpeg jako osobnego procesu na własnym serwerze nie jest ani dystrybucją, ani łączeniem. GPL nakłada obowiązki przy przekazywaniu oprogramowania, a wystawienie aplikacji przez HTTP nią nie jest. Nie ma tu odpowiednika sytuacji z AGPL — GPL 2 nie zawiera klauzuli sieciowej. Twój kod PHP nie staje się GPL przez to, że wywołuje binarkę GPL.

Aplikacja desktopowa lub mobilna z FFmpeg w środku: sprawa jest poważna. Tu następuje dystrybucja i licencja obowiązuje w pełni. Przy buildzie GPL musisz udostępnić źródła całej aplikacji na GPL. Przy buildzie LGPL (bez x264 i x265) warunki są znośne, ale konkretne: dynamiczne linkowanie z bibliotekami FFmpeg, udostępnienie źródeł FFmpeg odpowiadających dokładnie Twoim binarkom, instrukcja kompilacji, widoczna informacja o użyciu FFmpeg na stronie pobierania i w samej aplikacji, zakaz zmiany nazw bibliotek, wyraźne zrzeczenie się własności FFmpeg w EULA i usunięcie z umowy zakazu inżynierii wstecznej.

Patenty to osobna sprawa

Licencja na kod i licencja na algorytm to dwie różne rzeczy. Sami autorzy FFmpeg wprost piszą, że nie są w stanie stwierdzić, czy projekt implementuje algorytmy objęte patentami. Standardy H.264 i HEVC są otoczone pulami patentowymi (MPEG LA, Access Advance), które przy zastosowaniu komercyjnym potrafią upomnieć się o opłaty — niezależnie od tego, że sam kod jest wolny.

Jeżeli chcesz zejść z tego ryzyka, kodeki spod Alliance for Open Media są odpowiedzią: AV1 (przez libsvtav1 lub libaom-av1), VP9 (libvpx-vp9) i Opus (libopus). Wszystkie mają licencje typu BSD, wszystkie są zgodne z LGPL i wszystkie mają jawny grant patentowy. Wsparcie AV1 w przeglądarkach jest dziś powszechne, a SVT-AV1 kompresuje przy podobnej jakości wyraźnie lepiej niż x264. Koszt: kodowanie jest wolniejsze, a starsze urządzenia mobilne nie mają dekodera sprzętowego, więc odtwarzanie zjada im baterię. Bezpieczny kompromis, jeśli możesz sobie pozwolić na dwa razy więcej miejsca, to AV1 albo VP9 jako wariant główny i H.264 jako zapasowy.

Podsumowanie

FFmpeg nie jest wyborem architektonicznym, tylko warstwą infrastruktury, którą trzeba obsłużyć poprawnie. Dobra wiadomość jest taka, że „poprawnie” sprowadza się do skończonej i całkiem krótkiej listy rzeczy.

Co daje:

  • jedno narzędzie na dekodowanie, kodowanie, filtrowanie, przepakowywanie i analizę praktycznie każdego formatu audio i wideo, jaki istnieje,
  • ffprobe jako jedyne wiarygodne źródło prawdy o pliku wgranym przez użytkownika — MIME i rozszerzenie nic nie znaczą,
  • gotowe wyjście na web: H.264 z yuv420p i +faststart, HLS z wieloma jakościami, animowany WebP, miniatury i sprite'y do paska postępu,
  • normalizację głośności zgodną z EBU R128 przez dwuprzebiegowy loudnorm,
  • maszynowo czytelny postęp przez -progress pipe:1, dający się wpiąć w domknięcie Process::run() i pokazać użytkownikowi,
  • akcelerację sprzętową NVENC, VAAPI, QSV, VideoToolbox, D3D12 i Vulkana — z realnym kosztem jakości przy tym samym bitrate,
  • lokalną transkrypcję filtrem whisper (od 8.0), bez zewnętrznego API i bez opłaty za minutę,
  • siedem równolegle utrzymywanych gałęzi, od 4.4 do 9.0, więc aktualizacja bezpieczeństwa nie musi oznaczać skoku o wersję główną,
  • dojrzałą integrację z Laravelem przez protonemedia/laravel-ffmpeg 8.9.0, wpiętą w warstwę dysków, z szyfrowanym HLS i rotacją kluczy.

Licencja: rdzeń na LGPL 2.1+, ale build z libx264 lub libx265 — czyli ten, którego realnie używasz — jest na GPL 2+, a build z --enable-nonfree nie nadaje się do redystrybucji w żadnej formie. Dla aplikacji webowej i SaaS-u nie ma to praktycznych konsekwencji: uruchamianie procesu na własnym serwerze nie jest dystrybucją, a GPL 2 nie ma klauzuli sieciowej znanej z AGPL. Jeśli natomiast pakujesz FFmpeg do aplikacji desktopowej albo mobilnej, licencja obowiązuje w pełni i decyzja „GPL czy LGPL” musi zapaść przed pierwszym buildem, nie po. Do tego dochodzą patenty na H.264 i HEVC, które są kwestią odrębną od licencji na kod — jeśli chcesz je ominąć, właściwym wyborem są AV1, VP9 i Opus.

Na koniec rzecz, która nie mieści się w żadnej z powyższych kategorii, a jest chyba najważniejsza. FFmpeg utrzymuje garstka ludzi, a korzystają z niego wszyscy — łącznie z firmami, które zgłaszają im podatności wygenerowane przez AI, nie dokładając ani jednej łatki. Jeżeli Twój produkt stoi na tym kodzie, warto rozważyć wsparcie projektu przez stronę darowizn albo, jeszcze lepiej, wysłanie łatki. To jedna z niewielu zależności, którą naprawdę trudno byłoby czymkolwiek zastąpić.