Examples
A gallery of real-world recipes. Each pairs the PHP with the equivalent ffmpeg
command, so you can read the two shapes side by side.
Transcode to H.264 + AAC
Section titled “Transcode to H.264 + AAC”Re-encode video to H.264 at a chosen quality, audio to AAC.
use FFmpeg\{Media, MediaEncoder};use FFmpeg\Codec\{VideoCodec, AudioCodec};
$media = Media::open('in.mov');
(new MediaEncoder()) ->addVideo($media->videoStream(), VideoCodec::H264, crf: 20, preset: 'medium') ->addAudio($media->audioStream(), AudioCodec::AAC, bitrate: 128_000) ->save('out.mp4');ffmpeg -i in.mov -c:v libx264 -crf 20 -preset medium -c:a aac -b:a 128k out.mp4Remux to a different container
Section titled “Remux to a different container”Change the container with no re-encode — the target must accept the source codecs.
(new MediaEncoder()) ->addVideo($media->videoStream(), VideoCodec::Copy) ->addAudio($media->audioStream(), AudioCodec::Copy) ->save('out.mkv');ffmpeg -i in.mp4 -c copy out.mkvWork with a slice of a clip
Section titled “Work with a slice of a clip”Seeking is part of opening the file: from() with to() (absolute end) or take() (duration)
hands you just that span, as a fast input seek, with the timeline reset so the slice starts at
zero.
$clip = Media::open('lecture.mp4')->from(120.0)->take(60.0); // from 2:00, one minute// or by absolute end: ->from(120.0)->to(180.0)
(new MediaEncoder()) ->addVideo($clip->videoStream(), VideoCodec::H264) ->addAudio($clip->audioStream(), AudioCodec::Copy) ->save('highlight.mp4');ffmpeg -ss 120 -t 60 -i lecture.mp4 -c:v libx264 -c:a copy highlight.mp4Carry several audio tracks
Section titled “Carry several audio tracks”addAudio appends, so an output can hold as many tracks as you map — keep the original and
add a re-encoded commentary, say. (The same is true of addVideo.)
$media = Media::open('film.mkv');
(new MediaEncoder()) ->addVideo($media->videoStream(), VideoCodec::Copy) ->addAudio($media->audioStream(0), AudioCodec::Copy) // original, untouched ->addAudio($media->audioStream(1), AudioCodec::AAC) // commentary, re-encoded ->save('out.mkv');ffmpeg -i film.mkv -map 0:v:0 -map 0:a:0 -map 0:a:1 -c:v copy -c:a:0 copy -c:a:1 aac out.mkvGenerate a stream from nothing
Section titled “Generate a stream from nothing”Source filters synthesize a stream — silence, a tone, a solid color, bars, a test pattern — with no file behind them. Here, give a silent timelapse an audio track so players that expect one stay happy:
use FFmpeg\Filter\Anullsrc;
$video = Media::open('timelapse.mp4')->videoStream();
(new MediaEncoder()) ->addVideo($video, VideoCodec::Copy) ->addAudio(AudioStream::generate(new Anullsrc(duration: $video->duration)), AudioCodec::AAC) ->save('out.mp4');ffmpeg -i timelapse.mp4 -f lavfi -i anullsrc=r=44100:cl=stereo:d=$DURATION \ -c:v copy -c:a aac out.mp4generate() is the entry point for any source filter — a solid color, a tone, a test pattern, bars
— each named 1:1 in the filter catalog and each an ordinary stream you can then filter,
overlay onto, or encode:
use FFmpeg\{VideoStream, AudioStream};use FFmpeg\Filter\{Color, Sine, Smptebars};
$black = VideoStream::generate(new Color(color: 'black', size: '1920x1080', duration: 3.0));$tone = AudioStream::generate(new Sine(frequency: 440.0, duration: 2.0));$bars = VideoStream::generate(new Smptebars(size: '1280x720'));A source is infinite unless an option bounds it (a duration), or it’s combined with a stream that
does — so encoding one on its own needs a duration.
Normalize loudness (EBU R128)
Section titled “Normalize loudness (EBU R128)”Bring audio to a consistent perceived loudness; leave the video untouched. normalizeLoudness
is the friendly name for FFmpeg’s loudnorm.
$media = Media::open('in.mp4');
(new MediaEncoder()) ->addVideo($media->videoStream(), VideoCodec::Copy) ->addAudio( $media->audioStream()->normalizeLoudness(i: -16, tp: -1.5, lra: 11), AudioCodec::AAC, ) ->save('out.mp4');ffmpeg -i in.mp4 -af loudnorm=I=-16:TP=-1.5:LRA=11 -c:v copy out.mp4Concatenate clips
Section titled “Concatenate clips”Join clips end to end. The builder picks the fast path (stream-copy, when codecs match) automatically, and falls back to re-encoding for mixed inputs.
Media::concat( Media::open('intro.mp4'), Media::open('main.mp4'), Media::open('outro.mp4'),)->save('out.mp4');# fast, same-codec clips (list.txt holds: file 'intro.mp4' / file 'main.mp4' / …)ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4
# mixed inputs (re-encodes)ffmpeg -i intro.mp4 -i main.mp4 \ -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" \ -map "[v]" -map "[a]" out.mp4Overlay a logo / bug
Section titled “Overlay a logo / bug”Composite a PNG in a corner. Position is semantic (no W-w-20 arithmetic), with a raw
x/y escape hatch when you need it.
use FFmpeg\Filter\Position;
$media = Media::open('in.mp4');$logo = Media::open('logo.png')->videoStream();
(new MediaEncoder()) ->addVideo( $media->videoStream()->overlay($logo, Position::TopRight, margin: 20), VideoCodec::H264, ) ->addAudio($media->audioStream(), AudioCodec::Copy) ->save('out.mp4');ffmpeg -i in.mp4 -i logo.png -filter_complex "overlay=W-w-20:20" out.mp4Blurred-background letterbox
Section titled “Blurred-background letterbox”Split the clip in two: one branch fills the frame, blurred, as a background; the other rides on top at its real aspect ratio. One decode, two branches — no temp files.
use FFmpeg\{Media, MediaEncoder};use FFmpeg\Codec\VideoCodec;use FFmpeg\Filter\{BoxBlur, Position};
$clip = Media::open('in.mp4')->videoStream();[$bg, $fg] = $clip->split(2);
$out = $bg->scale(1920, 1080) ->apply(new BoxBlur(luma_radius: 40)) ->overlay($fg->scale(-1, 1080), Position::Center);
(new MediaEncoder())->addVideo($out, VideoCodec::H264)->save('out.mp4');ffmpeg -i in.mp4 -filter_complex \ "[0:v]split[bg][fg]; \ [bg]scale=1920:1080,boxblur=40[b]; \ [fg]scale=-1:1080[f]; \ [b][f]overlay=(W-w)/2:(H-h)/2" out.mp4split is a multi-output filter, so it hands back a list you destructure — and because the
branches are immutable nodes, both read from the same decode.
Duck music under a voice track
Section titled “Duck music under a voice track”Sidechain-compress the music so it dips whenever the voice plays, then mix them — the classic voiceover “ducking.”
$voice = Media::open('voice.wav')->audioStream();$music = Media::open('music.mp3')->audioStream();
(new MediaEncoder()) ->addAudio( $voice->mixWith($music->duckUnder($voice, threshold: 0.03, ratio: 8, release: 300)), AudioCodec::AAC, ) ->save('out.m4a');ffmpeg -i voice.wav -i music.mp3 \ -filter_complex "[1:a][0:a]sidechaincompress=threshold=0.03:ratio=8:release=300[ducked]; \ [0:a][ducked]amix=inputs=2[a]" \ -map "[a]" out.m4aHard-limit peaks
Section titled “Hard-limit peaks”Catch overshoots without obvious pumping.
(new MediaEncoder()) ->addAudio(Media::open('in.wav')->audioStream()->limit(peak: 0.9), AudioCodec::AAC) ->save('out.m4a');ffmpeg -i in.wav -af alimiter=limit=0.9 out.wavA parameter can be a number, an expression, or a closure
Section titled “A parameter can be a number, an expression, or a closure”Evaluated filter parameters — positions, sizes, angles, opacities — take three shapes. Pass
a plain value, an FFmpeg expression string, or a PHP closure that runs on every frame and
receives that filter’s own variables (here, CropVars — never the wrong filter’s):
use FFmpeg\Filter\CropVars;
$clip = Media::open('in.mp4')->videoStream();
// a number$clip->crop(1080, 1080, x: 420);
// an FFmpeg expression — centred horizontally, evaluated natively$clip->crop(1080, 1080, x: '(in_w-out_w)/2');
// a PHP closure, evaluated per frame$clip->crop(1080, 1080, x: fn (CropVars $v) => ($v->inWidth - $v->outWidth) / 2);Build filters in a loop
Section titled “Build filters in a loop”Filters are plain objects validated on construction, so you can build them as data — and a
bad expression throws inside the loop, naming the offending filter, before apply() ever
sees it.
use FFmpeg\Filter\{DrawText, DrawTextVars};
$captions = [];foreach ($cues as $cue) { $captions[] = new DrawText( text: $cue->text, y: fn (DrawTextVars $v) => $v->height - 160, enable: "between(t, {$cue->start}, {$cue->end})", // parsed now — a typo throws here );}
$clip->apply($captions)->apply($scale)->apply($overlay)->save('out.mp4');apply() takes a single filter or a flat list, so a data-built stack and hand-written steps
chain together naturally.
Run your own code on every frame
Section titled “Run your own code on every frame”Expression parameters are for math. When you need the actual pixels, mapFrames() hands
each decoded frame to a closure — in PHP’s own memory, with nothing written to disk. It’s a
filter you wrote in PHP:
use FFmpeg\Frame;
$media = Media::open('interview.mp4');
(new MediaEncoder()) ->addVideo( $media->videoStream()->mapFrames(function (Frame $frame) { foreach (MediaPipe::detectFaces($frame) as $face) { // ext-mediapipe — same memory OpenCV::blur($frame, $face->region); // ext-opencv — same frame } return $frame; }), VideoCodec::H264, ) ->addAudio($media->audioStream(), AudioCodec::Copy) ->save('out.mp4');There’s no ffmpeg line to set beside this one — that’s the point.
Iterate on a graph, one frame at a time
Section titled “Iterate on a graph, one frame at a time”Because streams are immutable and nothing renders until you ask, you can shape a graph the way
you’d shape a query in tinker — keep the source, derive a version, look at a frame, adjust,
look again:
$base = Media::open('interview.mp4')->videoStream();
// a first idea$draft = $base->scale(1280, 720)->drawText(text: 'DRAFT', x: 40, y: 40);$draft->frameAt(12.0)->save('preview.png'); // peek
// nudge it — $base is untouched, so just re-derive$draft = $base->scale(1280, 720) ->drawText(text: 'DRAFT', x: 40, y: fn (DrawTextVars $v) => $v->height - 80);$draft->frameAt(12.0)->save('preview.png'); // peek again
// happy with it? now pay for the full encode(new MediaEncoder())->addVideo($draft, VideoCodec::H264)->save('out.mp4');frameAt() renders just that one frame through the current filters, so the loop stays
snappy — seconds, not a full transcode. In a TUI, $frame->toPng() feeds a terminal image
protocol; in tinker, ->save() and open the file.
The CLI can grab a preview frame too:
ffmpeg -ss 12 -i interview.mp4 -vf "scale=1280:720,drawtext=text='DRAFT':x=40:y=40" \ -frames:v 1 -update 1 preview.pngThe difference is the loop: there, every adjustment means editing that command by hand, keeping it in sync with the real encode, and round-tripping through a file. Here it’s the same immutable graph you’re already building — and the frame comes back in memory, ready to inspect or hand to another extension.
Putting it all together
Section titled “Putting it all together”One realistic job: take a raw recording, scale it to 1080p, drop a logo in the top-right, normalize the speech, mix in background music ducked under that speech, and encode H.264 + AAC. This is where the two approaches diverge hardest.
The ffmpeg CLI — one keystroke from a typo, and good luck reading it back in six months:
ffmpeg -i raw.mov -i logo.png -i music.mp3 \ -filter_complex "\ [0:v]scale=1920:1080[bg]; \ [bg][1:v]overlay=W-w-40:40[v]; \ [0:a]loudnorm=I=-16:TP=-1.5:LRA=11[voice]; \ [2:a][voice]sidechaincompress=threshold=0.03:ratio=8:release=300[ducked]; \ [voice][ducked]amix=inputs=2:duration=first[a]" \ -map "[v]" -map "[a]" \ -c:v libx264 -crf 18 -preset slow -c:a aac -b:a 192k out.mp4The same job in PHP — reads like the description above, and the type system catches the typo:
use FFmpeg\{Media, MediaEncoder};use FFmpeg\Codec\{VideoCodec, AudioCodec};use FFmpeg\Filter\Position;
$raw = Media::open('raw.mov');$logo = Media::open('logo.png')->videoStream();$music = Media::open('music.mp3')->audioStream();
$voice = $raw->audioStream()->normalizeLoudness(i: -16, tp: -1.5, lra: 11);
(new MediaEncoder()) ->addVideo( $raw->videoStream() ->scale(1920, 1080) ->overlay($logo, Position::TopRight, margin: 40), VideoCodec::H264, crf: 18, preset: 'slow', ) ->addAudio( $voice->mixWith($music->duckUnder($voice, threshold: 0.03, ratio: 8)), AudioCodec::AAC, bitrate: 192_000, ) ->save('out.mp4');Notice $voice is used twice — once as the voiceover, once as the key that ducks the
music. Because streams are immutable nodes, the builder inserts the asplit for you (the CLI
makes you label and route that by hand).
The named methods here — scale, overlay, normalizeLoudness, duckUnder, mixWith —
are ergonomic sugar. Underneath, each is just apply(new \FFmpeg\Filter\…()), and every
filter in FFmpeg is reachable that way through a Filter class whose name mirrors the FFmpeg
filter one-to-one — so readability and complete coverage aren’t a trade-off:
$stream->apply(new \FFmpeg\Filter\Vignette(angle: M_PI / 5));These shapes are how we want the API to read. If a recipe you need doesn’t read this well yet, tell us — [email protected]. And if it saves you from a shell-escaping afternoon, sponsor the work and support FFmpeg.
An Artisan Build project.
Built on FFmpeg — an independent binding, not affiliated with or endorsed by the FFmpeg project. Support FFmpeg.