Burn subtitles into a video with FFmpeg
Render SRT or ASS captions into the picture with FFmpeg, with the escaping a subtitles= path or URL needs, force_style keys, and when to keep them soft.
To burn subtitles into a video with FFmpeg, pass the subtitle file to the subtitles filter:
ffmpeg -i input.mp4 -vf subtitles=subs.srt -c:a copy output.mp4The captions become part of the picture, so the video is re-encoded and cannot be turned off. If viewers should be able to turn them off, mux a soft track, which re-encodes nothing:
ffmpeg -i input.mp4 -i subs.srt -c copy -c:s mov_text output.mp4-c copy there copies the video and audio as they are, and mov_text is the subtitle format MP4 carries. Choose between the two on whether the viewer should have a choice, not on which command is shorter.
Escaping is the whole difficulty
The subtitles filter opens its file itself. Most filters work on streams FFmpeg already opened through -i. subtitles takes a filename as an option, opens it through libavformat (the same layer -i uses), and renders it with libass. That makes the filename an option value inside a filter graph, and filter graphs give : a meaning.
None of the raw subtitles= attempts in Rendobar’s job history completed. When I went back to the commands, the pattern was escaping.
Most wrote the URL straight into the filter: -vf subtitles=https://raw.githubusercontent.com/.../sample.srt. The filter’s option parser splits on :, so the filename became https and the rest of the URL became the next option, original_size. On ffmpeg 8.0 that fails with:
Unable to parse option value "//raw.githubusercontent.com/andreyvit/subtitle-tools/master/sample.srt" as image sizeThose same commands also attached -vf to a stream that -filter_complex already produced, which FFmpeg refuses on its own (“Simple and complex filtering cannot be used together for the same stream”), so each one carried two fatal errors. Another passed a name that was not a file on disk, which fails with Unable to open. The attempts that did escape the colon got further: their recorded message is libass printing its shaper versions, which it does only once the filter has started. The stored message stops there, so their final error is not on record.
An earlier version of this post said a remote URL does not work and the filter needs a path on disk. That was wrong. With the colon escaped, ffmpeg 8.0 burned captions from an https URL with no other change:
ffmpeg -i input.mp4 \ -vf "subtitles='https\://raw.githubusercontent.com/andreyvit/subtitle-tools/master/sample.srt'" \ -c:a copy output.mp4Two layers of escaping meet in that argument. The single quotes protect the value from the filter graph parser, which would otherwise treat characters such as , ; and [ as graph syntax. The backslash before the colon survives the quotes and tells the option parser that this : is part of the value. The outer double quotes are for the shell.
A Windows path has the same colon, after the drive letter. Use forward slashes and escape only that colon:
ffmpeg -i input.mp4 -vf "subtitles='C\:/videos/subs.srt'" -c:a copy output.mp4I ran that form on Windows with ffmpeg 8.0 and it rendered. Forward slashes avoid the backslash-doubling that makes the usual Windows examples so hard to get right.
Fonts are a quieter problem, and they did not break any of these attempts. When the named font is missing, libass substitutes another one and carries on: on ffmpeg 8.0 a force_style naming a font that does not exist still exited cleanly, with a single fontselect line in the log showing the substitute. So a missing font gives you the wrong face, not an error. The fontsdir option points libass at a folder of font files, and custom fonts in a video API fail silently shows how often that happens on the composed path, where about two in three text-bearing compose jobs named a font the image did not have.
Styling
force_style applies ASS style keys to an SRT, which has no styling of its own:
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.mp4Colours are &HBBGGRR&, blue-green-red, the reverse of the hex order CSS uses. White is &HFFFFFF& either way, but pure red is &H0000FF&.
BorderStyle=3 draws an opaque box behind the text, and BorderStyle=1 draws an outline around the glyphs. An outline usually reads better on real footage, and a box guarantees legibility. MarginV lifts the text off the bottom edge, where most players and platforms draw their own controls.
For more than one global style, convert to ASS and style each line in the file:
ffmpeg -i subs.srt subs.assffmpeg -i input.mp4 -vf ass=subs.ass -c:a copy output.mp4The filter name changes to ass for ASS files. subtitles also reads ASS, but ass skips a conversion step.
Captions and bitrate
Burned captions are sharp, high-contrast edges in the same screen region on every captioned frame, which is hard for a video codec. If you see ringing around the letters at your usual CRF, lowering it a couple of steps is the usual lever, at the cost of a larger file:
ffmpeg -i input.mp4 -vf subtitles=subs.srt -c:v libx264 -crf 20 -preset medium -c:a copy output.mp4I have not measured the size or quality cost of that trade on a captioned clip, so treat the CRF value as a starting point to check by eye.
Burning captions as a job
Rendobar’s caption.burn job takes the video and the subtitle file as URLs and handles the fonts and the escaping itself, so none of the section above applies. Median costs from completed jobs in the job history:
| Operation | Median cost per job |
|---|---|
Burn a subtitle file (caption.burn) | $0.0095 |
Animated word-level captions (captions.animate) | $0.0200 |
Animated captions cost about twice a static burn at the median. The two ran on different clips of different lengths, so the ratio is rough, not a measured multiple. Both cost several times a plain FFmpeg job, because rendering text into every frame means a full re-encode.
import { createClient } from "@rendobar/sdk";
const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
const job = await rb.jobs.run({type: "caption.burn",inputs: { // Both are URLs. No escaping, no font setup. 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.
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.
Choosing between burned and soft
Burn when the video will be re-uploaded somewhere you do not control. Social platforms strip subtitle tracks, most feeds play muted, and a soft track nobody sees is not a caption.
Keep them soft when you own the player. Viewers can turn them off, one file can carry several languages, you can restyle without re-encoding, and the text stays machine-readable.
Ship both when it matters. A burned copy for distribution and a soft copy for your own site is two commands.
My one firm rule: never 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.
Every command here assumes you already have an SRT or ASS file, and none of it was tested on right-to-left scripts, vertical video or long-form content, all of which change the layout.
Frequently asked questions
How do I burn subtitles that are already inside an MKV?
Point the subtitles filter at the MKV itself and pick the track with si. ffmpeg -i input.mkv -vf "subtitles=input.mkv:si=0" -c:a copy output.mp4 burns the first subtitle stream. No separate SRT file is needed.
