Burn subtitles into videos with Lua and FFmpeg
Use Lua to run FFmpeg and burn an existing SRT file into a video. The script below produces an MP4 with burned subtitles and copied audio. A generated test video lets you check the text and timing before using your own files.
Burned subtitles become video pixels. Viewers cannot switch them off or select another language, and screen readers cannot read them as a text track. Keep the SRT separately if your player needs selectable captions. This example renders supplied text; it does not transcribe or translate audio.
Check the Linux prerequisites
Use a POSIX shell, /dev/fd, Lua 5.4 or 5.5, and FFmpeg with libass’s subtitles filter and the
libx264 encoder. The walkthrough targets Linux. The commands below install the prerequisites on
Ubuntu 24.04; other package builds may offer different FFmpeg features. The decode check also uses
mktemp, cat, and rm from the usual Linux coreutils package.
The example was tested with Ubuntu’s Lua 5.4.6 and FFmpeg 6.1.1, and with Lua 5.5.1 and FFmpeg 9.0.1. Use maintained packages rather than installing an old release just to match those tested versions.
Install Lua, FFmpeg, and a font with the characters used in this example:
sudo apt-get update && sudo apt-get install lua5.4 ffmpeg fonts-dejavu-core
Check the executable named lua before creating files. If your distribution provides only
lua5.4, use that name in each Lua invocation below. FFmpeg’s
subtitles filter documentation explains the
libass requirement.
lua -v &&
ffmpeg -hide_banner -h filter=subtitles &&
ffmpeg -hide_banner -h encoder=libx264 &&
ffprobe -version &&
command -v mktemp && command -v cat && command -v rm
Stop if the filter or encoder is unknown. Lua runs the shell command; FFmpeg does the decoding, rendering, and encoding. No Lua media library is needed.
Create a video and timed subtitles
Work in a new private directory with no concurrent writers. Generate an 8.25-second black video with a 660 Hz test tone and AAC audio. The small frame size and bounded encoder threads keep this check inexpensive:
(
mkdir lua-subtitles-demo && cd lua-subtitles-demo &&
ffmpeg -nostdin -n -f lavfi -i 'color=c=black:s=640x360:r=24:d=8.25' \
-f lavfi -i 'sine=frequency=660:sample_rate=48000:duration=8.25' \
-c:v libx264 -threads 2 -pix_fmt yuv420p -c:a aac -shortest input.mp4
)
When that command succeeds, enter the new directory for the remaining steps:
cd lua-subtitles-demo
Save the following as subtitles.srt in UTF-8. SRT timestamps use a comma before milliseconds;
blank lines separate cues. The accented word checks that your font and encoding agree.
1
00:00:01,000 --> 00:00:02,000
Hello from Lua: café!
2
00:00:04,000 --> 00:00:05,000
The second subtitle.
If setup stops partway through, keep the files it created. FFmpeg’s -n refuses an existing
input.mp4. Inspect it before retrying, or start again in a different new directory; do not repeat
the mkdir command inside the first one.
Save and run the Lua program
Save this as add_subtitles.lua beside the video and SRT. It quotes shell arguments and passes the
subtitle file through descriptor 3, so its filename never becomes part of FFmpeg’s filter syntax.
This handles spaces, apostrophes, colons, brackets, and leading hyphens in local filenames.
#!/usr/bin/env lua
if #arg ~= 0 and #arg ~= 3 then
io.stderr:write("Usage: lua add_subtitles.lua <video.mp4> <subtitles.srt> <new-output.mp4>\n")
os.exit(1)
end
local video_file = arg[1] or "input.mp4"
local subtitles_file = arg[2] or "subtitles.srt"
local output_file = arg[3] or "output.mp4"
local existing = io.open(output_file, "rb")
if existing then
existing:close()
io.stderr:write("Output already exists; choose a new filename.\n")
os.exit(1)
end
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function local_path(value)
if value:sub(1, 1) == "/" then return value end
return "./" .. value
end
local filter = "subtitles=/dev/fd/3"
local command = string.format(
"ffmpeg -nostdin -n -filter_threads 1 -i %s -vf %s -c:v libx264 -threads 2 -crf 23 -c:a copy %s 3<%s",
shell_quote(local_path(video_file)),
shell_quote(filter),
shell_quote(local_path(output_file)),
shell_quote(local_path(subtitles_file))
)
local success = os.execute(command)
if not success then
io.stderr:write("Subtitle conversion failed. Inspect any partial output before retrying.\n")
os.exit(1)
end
print("Subtitles added successfully.")
Run it with the video, subtitle file, and a new output filename, in that order:
lua add_subtitles.lua input.mp4 subtitles.srt output.mp4
After FFmpeg finishes, the program prints:
Subtitles added successfully.
The program reports an existing output as an error, and -n tells FFmpeg not to replace it. It uses
Lua’s shell command status to withhold
the success message when FFmpeg fails. This is not transactional publication: a failed conversion
can leave a new partial file, and the existence check does not coordinate concurrent writers.
Inspect any partial output and choose a fresh output path before retrying.
Check the visible result and audio
Open output.mp4 in your video player. You should hear the tone throughout, see “Hello from Lua:
café!” from 1 to 2 seconds, and see “The second subtitle.” from 4 to 5 seconds. The background
should be black without text outside those intervals. Check the accent as well as the timing;
successful encoding alone does not prove readable subtitles.
Probe the streams and fully decode the output as an additional check:
ffprobe -v error -show_entries stream=codec_type,codec_name -of compact output.mp4 &&
(
decode_log=$(mktemp) || exit 1
trap 'rm -f "$decode_log"' EXIT
if ffmpeg -nostdin -v error -xerror -i output.mp4 -map 0:v:0 -map 0:a:0 -f null - \
2>"$decode_log" && [ ! -s "$decode_log" ]; then
printf 'Full decode finished without errors.\n'
else
cat "$decode_log" >&2
false
fi
)
For the generated fixture, expect H.264 video, AAC audio, and “Full decode finished without
errors.” The subshell checks FFmpeg’s error output as well as its status: some builds report a
decoder error while returning status zero. It removes its temporary log and keeps your caller
shell running on either outcome. This check does not judge the caption text or timing; keep the
visual check. With your own media, use a valid local video and an audio codec supported by MP4
because -c:a copy copies the selected audio stream without converting it.
Copying also means the Lua command can finish with a damaged audio packet still in the output. The full decode above can expose that damage even when the stream listing looks correct. The success message alone is not an integrity check.
Delay the subtitles or choose a font
To delay every cue by 2.5 seconds, create a separate shifted SRT and pass that file to the same
program. FFmpeg’s -itsoffset option adds the offset
to the input timestamps:
ffmpeg -nostdin -n -itsoffset 2.5 -i subtitles.srt -c:s srt shifted.srt &&
lua add_subtitles.lua input.mp4 shifted.srt delayed.mp4
In delayed.mp4, the first cue should appear from 3.5 to 4.5 seconds and the second from 6.5 to
7.5 seconds. There should be no text at 1.5 seconds. Open shifted.srt to check the changed
timestamps if the result still follows the original timing.
For a fixed font and size, replace only the filter variable in the saved program with this line.
DejaVu Sans must be installed:
local filter = "subtitles=/dev/fd/3:force_style='FontName=DejaVu Sans,FontSize=24'"
Run the changed program with a new output filename:
lua add_subtitles.lua input.mp4 subtitles.srt styled.mp4
Keep this style string fixed in the script. Arbitrary user-supplied filter expressions need their
own validation; shell quoting does not validate FFmpeg filter syntax. FFmpeg documents the
force_style settings in the same subtitles filter reference linked above.
Use your own files and diagnose failures
Pass your own paths as the three arguments. The originals remain inputs; choose a separate output
with an .mp4 extension. Missing files, an invalid SRT, and audio that cannot be copied into MP4
should produce a nonzero exit status. Read FFmpeg’s diagnostic before retrying. This small wrapper
does not validate every packet or every subtitle cue; decoders can recover damaged media, so compare
the result with the original through the end of the recording.
If accented characters render incorrectly, confirm the SRT is UTF-8 and the chosen font contains them. Convert a known legacy encoding to a separate file rather than overwriting the source:
iconv -f ISO-8859-1 -t UTF-8 legacy.srt > converted.srt
Use an unused destination for that redirection, which can otherwise overwrite a file even when conversion fails. Do not pass untrusted uploads straight to this local command: run FFmpeg in an isolated worker with filesystem, network, memory, and execution-time limits. Shell quoting is not a media sandbox.
