Architecture
The API rests on a small number of deliberate decisions. They’re worth reading once, because everything else follows from them — once you know these, the rest of the surface is predictable.
Streams are immutable nodes
Section titled “Streams are immutable nodes”Opening a file probes it eagerly and hands you typed, readonly stream views. Applying a
filter returns a new node rather than mutating the one you had. That immutability is what
makes fan-out unambiguous: reuse a stream in two places and the builder knows to duplicate it
rather than guess. (It’s why the ducking example can use $voice as both the voiceover and
the key that ducks the music, and the asplit just appears.)
A node’s source can be a file (Media::open), a slice of one, another
filter, or a generator — from there on it’s all the same kind of node.
A clip can be a window of a file
Section titled “A clip can be a window of a file”Seeking belongs to the input, not to a filter. from(), to(), and take() open a file as
just a slice of itself — a fast demuxer seek, applied to the whole input so video and audio stay
in lock-step:
$clip = Media::open('lecture.mp4')->from(120.0)->to(180.0); // the minute from 2:00 to 3:00$clip = Media::open('lecture.mp4')->from(120.0)->take(60.0); // same span, by lengthto (absolute end) and take (duration) are sugar for each other — use whichever reads better.
(take, not duration, because Media::duration is already the readonly length property.)
The window is immutable and it resets the timeline. Once you’ve taken a slice, the slice is
the clip: it starts at t = 0, ->duration reports its own length, and frameAt(), the
expression variable t, and any trimming all speak the slice’s own time. Frames outside the
window simply don’t exist as far as your code is concerned.
For per-stream, in-graph precision there are also the trim / atrim filters — FFmpeg’s own
“pick one continuous section” filters, applied like any other. The input window is the fast,
whole-input default; reach for the filters when one stream needs its own cut inside the graph.
Nothing runs until save()
Section titled “Nothing runs until save()”Filters and mappings only record intent. The filter graph is assembled, configured, and
executed in one place — save(). The builder lowers your nodes into an FFmpeg filter graph
then, inserting split / asplit automatically wherever a node feeds more than one consumer,
so you never hand-label pads the way the CLI’s -filter_complex makes you. That same laziness is
what lets frameAt() render a single frame for a preview
instead of encoding the whole clip.
Names mirror FFmpeg
Section titled “Names mirror FFmpeg”Every filter is a class whose name is the FFmpeg filter name (FFmpeg\Filter\DrawText,
…\Loudnorm, …\Vignette) — a one-to-one, mechanical mapping, so your FFmpeg knowledge and a
decade of wiki pages and answers transfer directly, and the catalog stays complete. The
friendly stream methods (scale, overlay, normalizeLoudness) are ergonomic sugar over
apply(new Filter\…()). Readability and completeness aren’t a trade-off: the sugar reads
well, the classes cover everything.
How the filter catalog is built
Section titled “How the filter catalog is built”There are hundreds of FFmpeg filters, and none of them are written by hand. FFmpeg describes
every filter and its options at runtime — name, type, default, range — through its AVOption
metadata, the same data the ffmpeg CLI reads to turn "drawtext=text=…" into a configured
filter. ext-ffmpeg builds on that two ways:
- A dynamic core. Any filter can be invoked by name: the extension resolves it, validates the options against that metadata (expressions parse on the spot), and applies it. This alone covers the entire bundled FFmpeg — including filters we never special-cased.
- A generated typed layer. Because a released build bundles a known FFmpeg, that same
metadata is read at build time to generate the typed
Filterclasses, and to keep each filter page’s parameter facts current. The generated layer is what gives you editor autocomplete, named arguments, and PHPStan-level checking — static safety over the dynamic core, in sync with the bundled version by construction. (Each page’s explanations, examples, and playground are hand-written; generation refreshes the facts around them and scaffolds missing pages — it never overwrites the prose.)
So coverage is a generation problem, not a writing one: the dynamic core works the moment a filter exists in FFmpeg, and the typed surface follows from the same source of truth.
Wiring filters: one output, or many
Section titled “Wiring filters: one output, or many”A Filter is pure configuration — its options, and nothing about which streams feed it. Wiring
lives on a separate, immutable layer, a FilterNode, that binds inputs to a filter and exposes
its outputs.
You rarely see the node. For the common case — a single output — apply() builds it and hands
back that one output stream, so the chain continues; apply($f, ...$extra) is exactly
addFilter($f, $extra)->outputs()[0]. It also takes extra input streams for multi-input filters,
or a flat list to apply in sequence:
$clip->apply($scale)->apply($overlay, $logo); // extra inputs are positional$clip->apply([$crop, $scale, $vignette]); // a flat list chains in orderWhen a filter has more than one output (split), you build the node — stream-first via
addFilter(), or directly with new FilterNode(...) — and read its outputs by pad. The builder
lowers the immutable nodes into one graph, building a shared node once so its outputs can fan out
and recombine. The full surface is on How a filter attaches.
Filters also carry their shape as interfaces — media (VideoFilter/AudioFilter), output
arity (SingleOutputFilter/MultiOutputFilter), and source-ness
(TransformFilter/SourceFilter) — all generated from FFmpeg’s pad metadata. Methods gate on
the combination with intersection types, so the wrong filter in the wrong place fails at author
time: $video->apply(new Loudnorm()) (audio on video) and apply(new Split(2)) (multi-output)
are both type errors, not runtime surprises.
A parameter is a number, an expression, or a closure
Section titled “A parameter is a number, an expression, or a closure”Evaluated filter parameters accept int | float | string | Closure:
- a number is a static value,
- a string is an FFmpeg expression, evaluated natively by the engine,
- a closure is your PHP, run per frame, receiving that filter’s own variables as a typed
object (
DrawTextVars,CropVars) — never a foreign filter’s, and nevernullproperties that don’t apply.
A closure is typed Closure, not callable, so a string is always unambiguously an
expression — never a function name.
Expressions are parsed on construction
Section titled “Expressions are parsed on construction”When a parameter is an expression string, it goes to FFmpeg’s own av_expr_parse() the moment
the filter is constructed — the same parser the engine uses at run time. A malformed
expression, or one referencing a variable the filter doesn’t expose, throws a typed exception
right there: build three hundred filters in a loop, fat-finger one, and the throw lands on
that iteration with the offending filter’s params in scope — before apply() is ever called.
Two kinds of per-frame code
Section titled “Two kinds of per-frame code”There are deliberately two, with different costs and capabilities:
- Expression parameters do math — scalar variables (
t,n,w,text_w…), evaluated cheaply inside FFmpeg. They never touch pixels. mapFrames(Closure(Frame): Frame)hands you the actual decodedFrame— real pixels, in PHP’s own memory — to rewrite or pass to a sibling extension and back.
Frames are mappable, but they are not reachable from inside a filter expression. Keeping the two apart is what keeps the cheap path cheap and the powerful path honest about its cost.
Errors are typed, and local where they can be
Section titled “Errors are typed, and local where they can be”Every failure is an FFmpeg\Exception\* carrying the operation that failed (->operation) and
FFmpeg’s own error text (->avError). Checks that are cheap happen early and locally — the
expression parse above, “is this encoder built in” at the add* call. Errors only FFmpeg can
surface (graph configuration, mid-encode) happen at save(), but every node remembers where it
was defined, so the exception names the offending node and its origin.
Output: a stream, or a composed file
Section titled “Output: a stream, or a composed file”A single stream can save itself: $stream->save('out.mp4'). To assemble several tracks into one
file, MediaEncoder composes them — addVideo / addAudio append, so multiple video and
audio tracks are fine. Either way save() is stateless and re-runnable: it builds the output,
writes it, and tears everything down within the call.
An Artisan Build project.
Built on FFmpeg — an independent binding, not affiliated with or endorsed by the FFmpeg project. Support FFmpeg.