# How to burn subtitles into a video

Canonical: https://rendobar.com/blog/ffmpeg-burn-subtitles/
Author: Abdelrahman Essawy
Published: 2026-08-20
Updated: 2026-08-20

---

## Key takeaways

- Burning subtitles re-encodes the video, so -c:v copy is impossible. Soft subtitles are a mux and cost almost nothing, which is the trade.
- All 8 raw subtitles= attempts in our archive failed. The filter needs a local file path and font access, neither of which a remote URL gives it.
- Burning captions as a job type took a median of 5,978 ms of encode time and cost a median of $0.0119 across 24 completed runs.
- Animated word-level captions cost a median of $0.0200 across 68 runs, roughly 1.7x the cost of burning a static SRT.
- The subtitles filter path needs escaping on Windows and inside filter_complex, which is the single most common reason a working command stops working.

## Short version

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

| Operation | Completed jobs | Median cost | Median encode |
|---|---|---|---|
| Burn a static caption track | 24 | **$0.0119** | 5,978 ms |
| Animated word-level captions | 68 | **$0.0200** | 4,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.

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);`}
  curl={`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"
    }
  }'`}
/>

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](/blog/ffmpeg-errors-explained/). For the encoder settings that decide how the text survives compression, see [FFmpeg encoding settings measured](/blog/ffmpeg-encoding-settings/).
