Skip to content

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.

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');
Terminal window
ffmpeg -i in.mov -c:v libx264 -crf 20 -preset medium -c:a aac -b:a 128k out.mp4

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');
Terminal window
ffmpeg -i in.mp4 -c copy out.mkv

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');
Terminal window
ffmpeg -ss 120 -t 60 -i lecture.mp4 -c:v libx264 -c:a copy highlight.mp4

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');
Terminal window
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.mkv

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');
Terminal window
ffmpeg -i timelapse.mp4 -f lavfi -i anullsrc=r=44100:cl=stereo:d=$DURATION \
-c:v copy -c:a aac out.mp4

generate() 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.

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');
Terminal window
ffmpeg -i in.mp4 -af loudnorm=I=-16:TP=-1.5:LRA=11 -c:v copy out.mp4

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');
Terminal window
# 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.mp4

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');
Terminal window
ffmpeg -i in.mp4 -i logo.png -filter_complex "overlay=W-w-20:20" out.mp4

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');
Terminal window
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.mp4

split 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.

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');
Terminal window
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.m4a

Catch overshoots without obvious pumping.

(new MediaEncoder())
->addAudio(Media::open('in.wav')->audioStream()->limit(peak: 0.9), AudioCodec::AAC)
->save('out.m4a');
Terminal window
ffmpeg -i in.wav -af alimiter=limit=0.9 out.wav

A 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);

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.

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.

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:

Terminal window
ffmpeg -ss 12 -i interview.mp4 -vf "scale=1280:720,drawtext=text='DRAFT':x=40:y=40" \
-frames:v 1 -update 1 preview.png

The 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.

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:

Terminal window
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.mp4

The 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.