Filters are where FFmpeg commands stop being readable. The syntax is genuinely small, and almost every impenetrable command you have copied from somewhere is one of two shapes.
The simple shape: -vf
ffmpeg -i in.mp4 -vf "scale=1280:-2,fps=30" out.mp4
That is a chain. One input, one output, filters applied in order, separated by commas. The video is scaled, then the frame rate is changed.
Order matters, and it matters for speed as much as for the result. Scaling down before an expensive filter means the expensive filter processes fewer pixels.
The -2 in the scale is worth knowing on its own: it means "work out the height from the width, and round to an even number". Many encoders reject odd dimensions, and this is the idiom that avoids the failure.
The audio equivalent is -af, with the same rules.
When -vf is not enough
The moment you have more than one input or more than one output, you need -filter_complex. That is the whole rule.
A watermark is two inputs. Mixing two audio files is two inputs. Producing three sizes in one pass is three outputs. All of them need the complex form, and trying them with -vf produces errors that do not explain themselves.
Labels
ffmpeg -i video.mp4 -i logo.png -filter_complex \ "[0:v][1:v]overlay=W-w-10:H-h-10[out]" \ -map "[out]" -map 0:a -c:a copy out.mp4
The bracketed names are labels, and they are how the graph is wired.
[0:v] is the video stream of input 0. The first -i. [1:v] is the video of input 1. The overlay filter takes both and produces a result, which is named [out] so that -map can select it for the output file.
In overlay, uppercase W and H are the main video's dimensions and lowercase w and h are the overlay's, so W-w-10 is "ten pixels in from the right edge". Adding watermarks and overlays deals with the positioning in full.
Comma versus semicolon
This is the distinction that causes most syntax errors.
A comma chains filters: the output of one becomes the input of the next, implicitly.
A semicolon ends a chain and starts a separate branch. Branches connect to each other only through labels.
[0:v]scale=1280:-2[big];[0:v]scale=640:-2[small]
Two branches, both reading the same source, producing two named results. Written with a comma instead, it would mean "scale to 1280, then scale that to 640", which is a different and much worse thing.
Splitting a stream
A stream can only feed one filter chain. To use it twice, split it explicitly:
ffmpeg -i in.mp4 -filter_complex \ "[0:v]split=2[a][b];[a]scale=1280:-2[hd];[b]scale=640:-2[sd]" \ -map "[hd]" hd.mp4 -map "[sd]" sd.mp4
One decode, two outputs. That is meaningfully cheaper than running FFmpeg twice, because decoding the source is often the expensive part.
The audio equivalent is asplit.
Reading the errors
Three cover most of them.
"Invalid argument" after a filter usually means a parameter name is wrong for your FFmpeg version. Check with ffmpeg -h filter=overlay, which lists exactly what that build accepts.
"Output pad not connected" means you produced a labelled result and never mapped it, or mapped a label you did not create. Read the labels as a list and match them up.
"Filter not found" means your build lacks it. ffmpeg -filters lists what is present, and this is the one error that is not a syntax mistake.
Troubleshooting common FFmpeg errors goes over the rest, and ffprobe is how you confirm what your streams actually are before writing a graph against them.
Filters that take no input
Some filters generate rather than transform, and they are how you produce something from nothing:
ffmpeg -f lavfi -i color=c=black:s=1280x720:d=5 -c:v libx264 black.mp4 ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 -t 10 test.mp4 ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo -t 10 silence.wav
-f lavfi tells FFmpeg the input is a filter instead of a file.
These matter more than they look. Generated silence is how you give a video an audio track it never had, which some players and platforms require. Generated colour is how you pad a clip to a fixed length. And the test source is how you check whether a problem is your file or your command: if the test pattern encodes correctly and your file does not, the fault is in the file.
Where a filter runs, and why order changes the cost
Filters run in the order written, on every frame. That makes ordering a performance decision as much as a visual one.
-vf "scale=640:-2,unsharp" # sharpen 640-wide frames -vf "unsharp,scale=640:-2" # sharpen full-size frames, then shrink
Both produce a sharpened 640-wide video and the second does several times the work, because the expensive filter processed every pixel of the original.
The general rule is to reduce the frame size early and do expensive work afterwards, unless the filter genuinely needs the original detail, which is true of stabilisation and deinterlacing. Those must come first, and deinterlacing and changing frame rate goes over why the damage from getting it wrong cannot be undone.
Checking a graph before running it
ffmpeg -filter_complex "..." -f null - 2>&1 | head -30
Sending the output to the null muxer runs the whole chain and writes nothing. Syntax errors, unconnected labels and missing filters all appear immediately, on a command that costs nothing to abandon.
For a long source, add -t 5 to process only the first few seconds. That turns a five-minute test into a two-second one, which changes how many iterations you are willing to make.
Finding out what a filter accepts
ffmpeg -h filter=overlay ffmpeg -h filter=drawtext ffmpeg -filters | grep -i scale
The first two list every parameter this build accepts, with defaults. That is more reliable than any documentation, because filters gain and lose options between versions and the error for an unrecognised parameter says only "Invalid argument".
When a command copied from elsewhere fails on a parameter, this is the fastest way to find out whether it is a typo or a version difference. See troubleshooting common FFmpeg errors.
Build filter chains one step at a time
A long chain that produces the wrong result is difficult to debug because any stage could be responsible. Add one filter, check the output, then add the next. It is slower to write and considerably faster than taking apart a chain of six that produces something unexpected. Keep the working intermediate command somewhere, since the version that worked before the last addition is the fastest way back when the addition turns out to be the problem.