How to burn captions into a video from the command line
Burning captions means drawing the text into the frames, so the result carries the words as pixels and every player shows them whether or not it supports subtitle tracks. ffmpeg does it in one command. The command is the easy part. What follows it here is the set of things that go wrong afterwards, each of which fails quietly: wrong font, wrong colour, captions out of sync, or a file that looks finished and has no captions in it at all.
The command
One line, and it is the whole answer for an unstyled burn.
ffmpeg -i input.mp4 -vf "subtitles=subs.srt" -c:a copy output.mp4The filter accepts SRT, WebVTT and ASS, and it needs ffmpeg built with libass. Check with ffmpeg -filters and look for subtitles in the list. A build without it will tell you the filter does not exist, which is the one failure in this whole page that announces itself clearly.
Note what is copied and what is not. The audio passes through untouched with -c:a copy, and the video cannot, because the filter's entire job is to change its pixels. Reaching for -c copy out of habit is the one mistake here that produces no error, no warning and an output file of plausible size containing no captions at all. If a burn appears to have done nothing, check that flag before anything else on this page.
Styling with force_style
An SRT carries no styling, so libass renders it in its own defaults, which are small, white and built for a television rather than for a product UI. The force_style option overrides them from the command line, and its field names and values are ASS style fields rather than anything ffmpeg invented.
Colours are the trap. They are written &HAABBGGRR, which reverses the byte order you are used to, so blue and red swap places and &H0000FF is red rather than blue. The alpha byte is inverted on top of that: 00 is fully opaque and FF is invisible. A style that renders nothing at all is usually an alpha written as though 00 meant transparent.
ffmpeg -i input.mp4 \
-vf "subtitles=subs.srt:force_style='FontName=Arial Black,FontSize=22,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=3,Shadow=0,Alignment=2,MarginV=60'" \
-c:a copy -crf 20 -preset medium -pix_fmt yuv420p output.mp4Two fields repay knowing. BorderStyle=1 gives an outline around the glyphs and BorderStyle=3 gives a filled box behind them, which is the one that stays readable over a bright product UI. Alignment uses the numpad layout, so 2 is bottom centre, 5 is middle centre and 8 is top centre, with MarginV measured from whichever edge the alignment anchors to.
FontSize is not pixels. It is a unit of the subtitle script's own coordinate space, which for an SRT the filter derives from the video dimensions, so the same number renders at a different apparent size on a 1080p source and a 4K one. Settle the number against the resolution you actually ship, and re-check it if you later change that.
When you need ASS instead of SRT
There are two filters, and the choice between them is about where the styling lives. subtitles reads SRT, VTT or ASS and lets the command line override the look. ass reads ASS only and renders exactly what the file says, with no override available. Reach for the second as soon as the styling is richer than a handful of fields.
ffmpeg -i input.mp4 -vf "ass=subs.ass" -c:a copy output.mp4This is the fork in the road for anything that highlights words as they are spoken. A line of SRT is one block of text over one time range and has nowhere to put a per-word timestamp. ASS emits one event per word, each carrying an inline colour tag for the word currently being said, which is why every tool that produces that style is generating ASS underneath whatever its interface looks like.
The four failures that do not announce themselves
1. Path escaping
The filename you pass is parsed twice: once by the filtergraph, which treats commas, semicolons and square brackets as syntax, and again by the filter, which treats a colon as the separator between its own options. A drive letter therefore reads as the end of the filename and the start of an option, which is why every Windows path needs its colon escaped and its backslashes turned into forward slashes.
# brittle
ffmpeg -i in.mp4 -vf "subtitles=C\:/clips/subs.srt" out.mp4
# what to do instead
cd /clips && ffmpeg -i in.mp4 -vf "subtitles=subs.srt" out.mp4Square brackets, semicolons and single quotes inside the filename are the same class of problem and are harder to escape than a colon. Rename the file.
2. Fonts resolve by name, and fail silently
FontName is matched against the font's internal family name through fontconfig, not against a file on disk. Name a family that is not installed and libass substitutes a default instead of failing, so the render succeeds and looks wrong. fontsdir points it at a directory of font files, which is the right answer in a container, but the family name still has to match what is inside the file rather than what the file is called.
fc-list | grep -i "arial black"
ffmpeg -i in.mp4 \
-vf "subtitles=subs.srt:fontsdir=./fonts:force_style='FontName=Arial Black'" \
-c:a copy out.mp4This is the single most common reason a burn that worked on a laptop looks wrong in CI. A minimal container image frequently ships no fonts at all, so everything falls back to one face or renders as empty boxes, and the build stays green throughout. If captions are part of a pipeline, assert on the font: render one frame, and compare it against a known-good reference.
3. Trimming and burning in the same pass
Combining -ss with the subtitles filter in a single invocation is the most tempting shortcut on this page and the one that produces the most confusing output. Seeking rebases the output timestamps to zero at the cut point, while the filter goes on reading cues from the subtitle file's own zero. The captions are not merely offset, they are the wrong captions: whatever was said in the first thirty seconds plays over a clip that starts a minute in. Run the two as separate passes.
4. Taking the default quality
The burn forces a re-encode, and caption text is where you see the cost first: a hard white glyph against a dark outline is a sharp high-contrast edge, which is precisely what a video codec handles worst. A screen recording is already an unkind source for the same reason, all straight lines and flat colour. Choose -crf yourself, around 18 to 20 for this kind of material, and add -pix_fmt yuv420p so older players and Safari do not refuse the file.
Getting the subtitle file in the first place
Cloud transcription APIs work and are accurate. They are also the wrong default for a product demo, because a screen recording of your own software routinely shows real accounts, real data and sometimes real customers, and uploading it to get a text file back is an avoidable disclosure. whisper.cpp runs the same Whisper model on your own CPU with no key, and once the model is on disk, no network at all.
ffmpeg -i input.mp4 -vn -ar 16000 -ac 1 -c:a pcm_s16le audio.wav
whisper-cli -m models/ggml-base.en.bin -f audio.wav -osrt
whisper-cli -m models/ggml-base.en.bin -f audio.wav --output-json-fullThe 16 kHz mono requirement is not a suggestion, which is why the audio pass exists at all. The two whisper invocations differ in one way that decides your styling options: -osrt writes an SRT, one block of text per time range, and that is all an SRT can hold. Word-level highlighting needs a timestamp per word, so it needs --output-json-full and the per-token offsets in the JSON, which you then turn into ASS events. Every tool that produces the per-word style is doing that conversion somewhere.
Model choice is the usual accuracy against time trade, with tiny, base and small covering almost every practical case. The variants ending in .en are English-only, and are both smaller and more accurate on English than the multilingual model of the same size.
Read the transcript before you burn it. General speech comes out well; your product name, your competitors and any technical jargon are the reliable misses, because no speech model has seen them. Correcting three words in a text file takes a minute, and a published demo that misspells your own product in letters a foot high does not get corrected at all.
Doing all of it in one command
Everything above is three steps you run in order: extract audio, transcribe, burn. Vidlet's CLI is those three steps behind one command. vidlet caption video.mp4 extracts the audio, transcribes it with whisper.cpp locally, generates ASS with word-level timing and burns it in with the ass filter, handling the path escaping described above. It defaults to a word-by-word highlight style, yellow, 48pt, bottom.
npm i -g @spark-apps/vidlet
vidlet caption video.mp4
vidlet caption video.mp4 --style classic --color cyan --position center
vidlet caption video.mp4 --srt corrected.srtFour styles are documented: hormozi (per-word highlight), karaoke (fill), classic (outlined) and minimal (boxed), with --font-size, --position and --model for the rest. Passing --srt skips transcription entirely, which is the flag to use on a second pass after you have corrected the product names.
The limits, plainly. Transcription is English only: the three bundled models are all .en variants and the language is pinned to English in the call, so a Spanish recording will not caption correctly. The prebuilt whisper.cpp binary covers Linux on x64 and arm64, which on Windows means WSL; on macOS you build whisper.cpp yourself and drop the binary in place. The first run downloads that binary from GitHub and the model from Hugging Face, roughly 150MB for base.en, after which nothing needs the network. Your video and its audio are never uploaded anywhere by this command, which is the part that matters when the recording shows real customer data.
The CLI needs no account. For the wider demo workflow around captions see CLI video editors for product demos and adding captions to a demo video, or the pricing page for what the paid web tiers add.
FAQ
What is the ffmpeg command to burn captions into a video?
ffmpeg -i input.mp4 -vf "subtitles=subs.srt" -c:a copy output.mp4. The subtitles filter draws the text into the frames, so the video stream has to be re-encoded; only the audio can be copied. If you pass -c copy the burn is skipped entirely and you get your original file back under a new name.
Why can I not use -c copy when burning subtitles?
Because burning changes pixels. A stream copy hands the encoded video through untouched, which is the opposite of what the filter is asked to do. Copy the audio with -c:a copy, let the video re-encode, and set -crf and -preset deliberately rather than taking whatever the default encoder picks.
How do I style burned-in subtitles with ffmpeg?
Pass force_style to the subtitles filter, for example subtitles=subs.srt:force_style='FontName=Arial,FontSize=24,OutlineColour=&H80000000,BorderStyle=3'. The names and values are ASS style fields, so colours are written &HAABBGGRR: blue and red are swapped relative to hex you are used to, and the alpha byte is inverted, where 00 is fully opaque and FF is invisible.
Why do my burned captions use the wrong font?
libass resolves fonts by family name through fontconfig, not by filename. If the family you named is not installed, it silently substitutes a default rather than failing, so the output renders in the wrong face with no error anywhere. Check with fc-list, install the font system-wide and run fc-cache, or point the filter at a directory with fontsdir=/path/to/fonts and still use the exact internal family name.
How do I escape the subtitle path in an ffmpeg filter?
The path is parsed twice, once by the filtergraph and once by the filter's own option parser, so a colon separates options and a comma separates filters. A Windows path like C:\clips\subs.srt has to become C\:/clips/subs.srt inside the quoted filter string. The reliable habit is to cd into the directory holding the subtitle file and pass a bare relative filename.
Why are my captions out of sync after trimming the video?
Because -ss resets the output timestamps while the subtitles filter keeps reading the file from its own zero, so the captions play from the start of the subtitle file over a clip that begins later. Do the two operations as separate passes, trimming first and burning second against a subtitle file generated from the trimmed clip, or shift every cue before you burn.
What is the difference between the subtitles filter and the ass filter?
The subtitles filter accepts SRT, VTT and ASS and lets you override styling from the command line with force_style. The ass filter takes ASS only and applies the styling written in the file, with no override. Use subtitles for a quick styled burn of an SRT; use ass when the file already carries per-word timing, karaoke tags or positioning that a command-line override cannot express.
How do I generate a subtitle file locally without a cloud API?
whisper.cpp runs OpenAI's Whisper model on your own CPU with no key and no upload. Extract 16 kHz mono PCM audio with ffmpeg -i input.mp4 -vn -ar 16000 -ac 1 -c:a pcm_s16le audio.wav, then run whisper-cli -m models/ggml-base.en.bin -f audio.wav -osrt. For word-level timing, which is what highlight styles need, ask for --output-json-full instead and read the per-token offsets.
Do burned-in captions reduce video quality?
The re-encode does, and caption text is the first thing to show it, because sharp high-contrast outlines are exactly what a video codec spends its bitrate badly on. Set -crf 18 to 20 for a demo or screen recording rather than relying on the default, and add -pix_fmt yuv420p so the result plays everywhere.