Skip to content

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.

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.

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 length

to (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.

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.

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.

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 Filter classes, 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.

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 order

When 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 never null properties that don’t apply.

A closure is typed Closure, not callable, so a string is always unambiguously an expression — never a function name.

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.

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 decoded Frame — 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.

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.