How to burn subtitles into a video

Render SRT or ASS captions permanently into the picture with FFmpeg, including the escaping rule that broke all 8 raw subtitle attempts in our job archive.

Share

Short version

Terminal window
ffmpeg -i input.mp4 -vf subtitles=subs.srt -c:a copy output.mp4

That burns the captions into the picture permanently. The video is re-encoded, which is unavoidable, because the text now is the video.

If you want subtitles the viewer can turn off, that is a different and much cheaper operation:

Terminal window
ffmpeg -i input.mp4 -i subs.srt -c copy -c:s mov_text output.mp4

-c copy there means nothing is re-encoded at all. The subtitle track is muxed alongside the video as a separate stream. Choose between them on whether the viewer should have a choice, not on which command is shorter.

The escaping problem is the whole difficulty

Every one of the 8 raw subtitles= attempts in our job archive failed. Not some of them. All of them.

The reason is that subtitles is not a normal filter. Most filters operate on streams FFmpeg already opened through -i. subtitles opens a file itself, using libass, which means it bypasses everything FFmpeg normally does for you. Three consequences follow, and each one broke attempts in our archive.

A remote URL does not work. The filter wants a path on disk. Passing an HTTP URL fails, and one of our attempts failed exactly this way while trying to reference a second -i input by name.

The path needs escaping, and the rules are unusual. A colon separates filter options, so a Windows drive letter terminates the path early. A single quote inside the filter string terminates the string. The safest form is to keep the subtitle file in the working directory with a plain ASCII name and reference it as subs.srt with no path at all.

Fonts must exist in the build. libass needs a font to render with. Two of our failures returned messages naming the text-shaping libraries, which is what a font resolution problem looks like from the outside. A font that is installed on your laptop is routinely absent from a container image.

For a Windows path, the escaped form looks like this, and the double backslashes and the escaped colon are both required:

Terminal window
ffmpeg -i input.mp4 -vf "subtitles='C\\:\\\\videos\\\\subs.srt'" -c:a copy out.mp4

If that looks fragile, it is. Copying the file next to the video and using a bare filename avoids the entire class of problem.

Styling

force_style accepts ASS style keys and applies them to an SRT, which has no styling of its own:

Terminal window
ffmpeg -i input.mp4 \
-vf "subtitles=subs.srt:force_style='FontName=Inter,FontSize=24,PrimaryColour=&HFFFFFF&,OutlineColour=&H000000&,BorderStyle=3,Outline=2,MarginV=40'" \
-c:a copy output.mp4

Two things about that are worth knowing before you fight it.

Colours are &HBBGGRR&, which is blue-green-red, the reverse of the hex order everyone expects. White is &HFFFFFF& and reads the same either way, but pure red is &H0000FF&. This catches people every single time.

BorderStyle=3 draws an opaque box behind the text, while BorderStyle=1 draws an outline around the glyphs. On real footage an outline usually reads better and a box guarantees legibility. MarginV lifts the text off the bottom edge, which matters because most players and platforms overlay their own controls there.

For anything more elaborate, convert to ASS first and style in the file, where you get per-line control instead of one global style:

Terminal window
ffmpeg -i subs.srt subs.ass
ffmpeg -i input.mp4 -vf ass=subs.ass -c:a copy output.mp4

Note the filter name changes to ass for ASS files. subtitles also reads ASS, but ass skips a conversion step.

Quality, and why captions cost bitrate

Burned captions are high-contrast hard edges over whatever is behind them, which is close to the worst case for a video codec. Sharp white-on-dark transitions do not compress well and they appear in exactly the same screen region on every frame that has a caption.

The practical effect is that the same CRF that looked fine on the source will show ringing around the text. Dropping two or three CRF steps for a captioned encode is usually enough:

Terminal window
ffmpeg -i input.mp4 -vf subtitles=subs.srt -c:v libx264 -crf 20 -preset medium -c:a copy output.mp4

That also makes the output larger, which costs upload time as well as storage. In our latency measurements the upload step ran a median of 578 ms against a median encode of 861 ms, so output size is not a free variable.

What it costs as a managed job

The escaping and font problems are why this exists as a job type rather than only as a command. Two measured lanes in the archive:

OperationCompleted jobsMedian costMedian encode
Burn a static caption track24$0.01195,978 ms
Animated word-level captions68$0.02004,374 ms

Animated captions cost roughly 1.7x a static burn. Both are several times the $0.0025 median of a plain FFmpeg job, which is the honest picture: rendering text into every frame is real work, not a mux.

The encode times are not directly comparable to each other, because the two lanes ran on different source lengths, and neither is a benchmark. They are what production runs happened to take.

job.ts
import { createClient } from "@rendobar/sdk";
const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
const job = await rb.jobs.run({
type: "caption.burn",
inputs: {
// A URL, which the raw subtitles= filter cannot take. Fonts come with it.
source: "https://cdn.rendobar.com/assets/examples/sample.mp4",
subtitles: "https://cdn.rendobar.com/assets/examples/sample.srt",
},
});
console.log(job.output.file.url);

Install with npm i @rendobar/sdk. jobs.run() submits and waits, so it returns the finished job in one call.

terminal
curl -X POST https://api.rendobar.com/jobs -H "Authorization: Bearer $RENDOBAR_API_KEY" -H "Content-Type: application/json" -d '{
"type": "caption.burn",
"inputs": {
"source": "https://cdn.rendobar.com/assets/examples/sample.mp4",
"subtitles": "https://cdn.rendobar.com/assets/examples/sample.srt"
}
}'

Returns immediately with a job id. Poll GET /jobs/{id} or register a webhook rather than blocking on the request.

That accepts the subtitle file as a URL, which the raw filter cannot do, and carries its own fonts.

Choosing between burned and soft

Burn when the video will be re-uploaded somewhere you do not control. Social platforms strip subtitle tracks, most play muted by default, and a soft track that nobody sees is not a caption.

Keep them soft when you own the player. Viewers get to turn them off, you can ship several languages in one file, you can restyle without re-encoding, and search engines can read the text.

Ship both when it matters. A burned copy for distribution and a soft copy for your own site is two commands and removes the compromise.

The one thing not to do is burn captions into a master. Burning is irreversible, and a master with captions welded into the pixels cannot be re-cut, re-translated or re-styled later.

Where this stops

The 8 raw attempts that all failed are a small and unflattering sample, and they were exploratory rather than a fair test of the filter. The filter works, on a local file, with fonts present. What the sample demonstrates is that those preconditions are easy to miss, not that the filter is broken.

The cost figures come from two different job lanes on different sources over five months, so they are production observations rather than a controlled comparison. The 1.7x ratio between animated and static captions should be read as an order of magnitude, not a measured multiple.

Nothing here covers transcription. Every command assumes you already have an SRT or ASS file. Generating one from audio is a separate operation with its own accuracy and cost questions.

We have not measured caption rendering on long-form video, on right-to-left scripts, or on vertical formats, all of which change the layout maths considerably.

For the error messages these commands produce, see common FFmpeg errors and what they mean. For the encoder settings that decide how the text survives compression, see FFmpeg encoding settings measured.

Frequently asked questions

How do I burn subtitles into a video with FFmpeg?

Use the subtitles filter with a local file: ffmpeg -i input.mp4 -vf subtitles=subs.srt -c:a copy output.mp4. The video is re-encoded because the captions become part of the picture, so -c:v copy cannot be used.

What is the difference between burned-in and soft subtitles?

Burned-in subtitles are pixels in the video and play everywhere with no viewer control. Soft subtitles are a separate stream the player renders, can be toggled or restyled, and cost almost nothing because the video is only muxed rather than re-encoded.

Why does my FFmpeg subtitles filter fail?

Usually the path. The subtitles filter opens a local file directly rather than going through FFmpeg's input handling, so a remote URL does not work and a Windows path needs its colon and backslashes escaped. All 8 raw attempts in our archive failed on some version of this.

How do I style burned-in subtitles?

Pass force_style with ASS style keys, for example subtitles=subs.srt:force_style='FontName=Inter,FontSize=24,PrimaryColour=&HFFFFFF&,BorderStyle=3'. For full control convert to ASS first, because ASS carries styling in the file itself.

Do burned-in subtitles reduce video quality?

Slightly, because the video is re-encoded. The text itself is high-contrast and hard to compress, so a burned caption track raises the bitrate needed for the same visual quality. Encoding at a lower CRF than usual compensates.

Sources

Tags #ffmpeg#subtitles#captions#srt
All posts
Share
  1. How to compress video to a target size Guides for the video API
  2. FFmpeg API pricing compared Guides for the video API
  3. FFmpeg encoding settings compared Guides for the video API