All posts
FFmpegEngineering11 Sep 2026 · 3 min read

From filter graph to FFmpeg command

JPJean Perez

FFmpeg gives you two different ways to describe a filter chain, and picking the wrong one for the job is how a stream you never asked for ends up in your output file.

The simple way is -vf for video or -af for audio: a comma-separated chain of filters applied to one stream. -vf "scale=1280:720,eq=brightness=0.1" reads as "take the video stream, scale it, then adjust brightness," in that order, and it's really shorthand for a filter graph with exactly one input and one output. Most single-stream edits never need more than this.

The moment a filter needs more than one input, produces more than one output, or you're combining streams from more than one file, -vf/-af runs out of room. That's what -filter_complex is for. It takes a full filtergraph description with labelled pads: names in square brackets that connect one filter's output to another filter's input, instead of relying on FFmpeg to guess which stream feeds which.

Reading a filter_complex

ffmpeg -i bg.mp4 -i logo.png \
  -filter_complex "[0:v]scale=1280:720[bg];[1:v]scale=200:-1[logo];[bg][logo]overlay=10:10[out]" \
  -map "[out]" -map 0:a -c:v libx264 out.mp4

[0:v] and [1:v] are input pads: the video stream from the first and second -i, respectively. scale=1280:720[bg] scales the background and labels its output bg, and scale=200:-1[logo] does the same for the logo, so a later stage can reach both by name. [bg][logo]overlay=10:10[out] takes those two labelled streams as its inputs and produces one labelled out. The semicolons separate filter stages the way a comma separates filters within one chain.

-map "[out]" is what actually sends that labelled output to the file. -map 0:a adds the first input's audio track alongside it, since a filtergraph output has to be mapped explicitly, and so does anything else you want in the file next to it.

The default mapping that fills gaps you left open

That last point is where a simple filter chain can surprise you. FFmpeg has a default stream-selection rule that runs whenever you don't pass -map at all: it picks one "best" stream of each type, video, audio, subtitle, from the available inputs and includes it in the output. That rule doesn't know or care what your filters touched.

Run -vf "scale=1280:720" against a file with both video and audio, with no -map anywhere on the command line, and the output gets a scaled video track and the source's original audio track, even though nothing you wrote mentioned audio. The filter only ever named the video stream. Default selection added the rest.

# no -map: default selection fills in audio you never asked for
ffmpeg -i in.mp4 -vf "scale=1280:720" -c:v libx264 -c:a aac out.mp4
# output: video (scaled) + audio (untouched, added by default selection)

# explicit map: only what's named makes it into the file
ffmpeg -i in.mp4 -filter_complex "[0:v]scale=1280:720[bg]" \
  -map "[bg]" -c:v libx264 out.mp4
# output: video only

The second form can't leak a stream you didn't route, because there's nothing left for default selection to fill in. Once at least one -map is present, FFmpeg stops picking streams on its own and includes exactly what you mapped. If you want the video-only result from a simple chain instead, you need -an alongside -vf, telling FFmpeg explicitly to drop audio. A tool generating commands from a graph the user built has an easier rule to hold to: always map, and nothing unrouted gets in.

Escaping happens twice

A value written inside a filter argument gets unescaped twice by FFmpeg before it's used: once when the filter option itself is parsed, and again when that value is read back out as part of the surrounding filtergraph description. Get the escaping right for the first pass and wrong for the second, and a value that looked fine on the command line comes out mangled once FFmpeg actually applies the filter. A backslash has to survive both passes to reach the filter as a literal backslash, so a command generator has to escape backslashes too, along with the characters that are obviously special like colons and commas.

The shell adds a third layer of quoting on top of both, which is a separate problem from FFmpeg's own two-pass parsing and the reason a filter argument that's correct inside ffmpeg -filter_complex "..." can still break when the same string gets built and quoted by a shell script.

Where the filter names and options come from

Nobody keeps all of this in their head. FFmpeg ships hundreds of filters, and each one takes its own set of named parameters, so a tool that builds filter graphs for a person to click through needs a catalog of what's actually available: every filter, its ports, its parameters, with the descriptions FFmpeg itself provides. BeemMeUp generates that catalog by running the real ffmpeg binary and reading back what it reports about itself, rather than hand-transcribing a filter list that would drift the next time FFmpeg adds one.