Add subtitles to videos in Ruby with open-source tools
Use Ruby and FFmpeg to burn an existing SRT file into video frames, with streamio-ffmpeg for
metadata and the srt gem for cue timing. The scripts below produce a captioned MP4, an SRT delayed
by 2.5 seconds, and a WebVTT sidecar. They work with captions you supply; they do not transcribe the
audio.
Why subtitles matter
Burned captions remain visible in players that cannot load a subtitle track. They become part of the picture, so viewers cannot switch them off or select a different language. Keep the original video and subtitle file if you also need a player-selectable track.
Open-source Ruby tools for subtitles
The two gems have different jobs:
- streamio-ffmpeg reads video metadata with FFprobe. The helper uses it to check for a video stream before encoding.
- srt parses SRT cues, shifts their timestamps, and writes SRT. WebVTT conversion below calls FFmpeg directly from Ruby.
These are older release lines: streamio-ffmpeg 3.0.2 was released in 2016 and srt 0.1.5 in
2021, according to their RubyGems
version pages. Their age makes a tested combination
useful; the wrapper's compatibility notes do not guarantee current FFmpeg releases.
Adding subtitles with streamio-ffmpeg
The examples were tested on Linux with Ruby 3.4.10 and FFmpeg/FFprobe 9.0.1. Those are tested
versions, not intrinsic minimums; newer maintained patches in those release lines are appropriate
to try with the checks below. Install Ruby 3.4 and
FFmpeg with FFprobe separately. You need the libx264, AAC,
and WebVTT encoders, libass's
subtitles filter, and a font such as DejaVu
Sans that covers your caption text.
Use Bash in a new writable directory, with RUBYOPT and BUNDLE_GEMFILE unset. Run this setup
there, then keep the same shell open for the later commands. Installation and gem metadata stay
in this directory. The explicit installation target also avoids a configured RubyGems user install.
export GEM_HOME="$PWD/.gems"
export GEM_PATH="$GEM_HOME"
export GEM_SPEC_CACHE="$PWD/.gem-spec-cache"
(
if [ -n "${RUBYOPT:-}" ] || [ -n "${BUNDLE_GEMFILE:-}" ]; then
printf 'Use a shell without Bundler or RUBYOPT overrides.\n' >&2
exit 1
fi
for tool in ruby gem ffmpeg ffprobe; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
gem install --norc --install-dir "$GEM_HOME" --no-user-install --no-document --ignore-dependencies \
multi_json:1.15.0 streamio-ffmpeg:3.0.2 srt:0.1.5
)
The list includes the wrapper's multi_json dependency, so dependency installation is disabled
and each selected version is activated in the helper below. If a download fails, rerun this block
in the same directory after fixing the connection. Check the installed FFmpeg build before creating
inputs:
ffmpeg -hide_banner -h filter=subtitles &&
ffmpeg -hide_banner -h encoder=libx264 &&
ffmpeg -hide_banner -h encoder=aac &&
ffmpeg -hide_banner -h encoder=webvtt &&
ffprobe -version
The help must describe each named filter or encoder. An “unknown” message means you need a different build, even if the help command exits successfully.
Save this shared implementation as subtitle_tools.rb. All examples below reuse it. The temporary
directory keeps the subtitle filename out of FFmpeg filter syntax. Publication uses a hard link
on the output filesystem; the filesystem must support hard links.
The wrapper's transcode implementation
validates readable output metadata without checking FFmpeg's exit status. The helper therefore uses
FFMPEG::Movie for metadata and calls FFmpeg with Ruby's
system, checking its status before
publishing a result.
gem 'multi_json', '1.15.0'
gem 'streamio-ffmpeg', '3.0.2'
gem 'srt', '0.1.5'
require 'streamio-ffmpeg'
require 'srt'
require 'tmpdir'
module SubtitleTools
def self.load_srt(path)
raise ArgumentError, 'Subtitle input must be a regular file' unless File.file?(path)
text = File.binread(path, 1024 * 1024 + 1)
raise ArgumentError, 'Subtitle input exceeds 1 MiB' if text.bytesize > 1024 * 1024
text.force_encoding(Encoding::UTF_8)
raise ArgumentError, 'Subtitles must be valid UTF-8' unless text.valid_encoding?
subtitles = SRT::File.parse(text)
if subtitles.lines.empty? || !subtitles.errors.empty?
raise ArgumentError, 'Invalid or empty SRT input'
end
unless subtitles.lines.all? { |line| line.start_time >= 0 && line.end_time > line.start_time }
raise ArgumentError, 'Invalid subtitle timing'
end
raise ArgumentError, 'Caption text must not be empty' if subtitles.lines.any? { |line| line.text.empty? }
subtitles
end
def self.with_output(output_path, extension)
output = File.join(File.realpath(File.dirname(output_path)), File.basename(output_path))
if File.exist?(output) || File.symlink?(output)
raise ArgumentError, 'Output must not exist'
end
Dir.mktmpdir('.subtitles-', File.dirname(output)) do |temporary|
candidate = File.join(temporary, "result#{extension}")
yield candidate, temporary
raise IOError, 'No nonempty output produced' unless File.file?(candidate) && File.size(candidate) > 0
File.link(candidate, output)
end
end
def self.burn(video_path, subtitle_path, output_path)
subtitles = load_srt(subtitle_path)
movie = FFMPEG::Movie.new(File.realpath(video_path))
raise ArgumentError, 'Invalid video input' unless movie.valid? && movie.video_codec
with_output(output_path, '.mp4') do |candidate, temporary|
File.write(File.join(temporary, 'captions.srt'), subtitles.to_s)
# Fixed basenames avoid FFmpeg's separate filter-path escaping rules.
unless system(FFMPEG.ffmpeg_binary, '-nostdin', '-n', '-i', movie.path,
'-c:v', 'libx264', '-c:a', 'aac', '-vf', 'subtitles=captions.srt',
'-threads', '2', candidate, chdir: temporary)
raise IOError, 'Video encoding failed'
end
end
end
def self.write_srt(subtitles, output_path)
with_output(output_path, '.srt') { |candidate| File.write(candidate, subtitles.to_s) }
end
def self.convert_to_vtt(subtitle_path, output_path)
subtitles = load_srt(subtitle_path)
with_output(output_path, '.vtt') do |candidate, temporary|
input = File.join(temporary, 'captions.srt')
File.write(input, subtitles.to_s)
unless system('ffmpeg', '-nostdin', '-n', '-i', input, '-c:s', 'webvtt', candidate)
raise IOError, 'Subtitle conversion failed'
end
end
end
end
The batch below calls this helper sequentially. It re-encodes the selected video to H.264 and audio, when present, to AAC. FFmpeg's default stream selection applies; it does not preserve every track in a multi-track source. Use verified local media: these snippets do not provide a sandbox for uploads.
Save this CLI boundary beside it as add_subtitles.rb:
require_relative 'subtitle_tools'
abort 'Usage: ruby add_subtitles.rb <video.mp4> <subtitles.srt> <new-output.mp4>' unless ARGV.length == 3
begin
SubtitleTools.burn(*ARGV)
puts 'Subtitles added'
rescue StandardError
warn 'Subtitle processing failed. Check the inputs, output path, and FFmpeg installation.'
exit 1
end
Use your MP4 and a UTF-8 SRT file, or generate this short test video. The command refuses an
existing input.mp4:
ffmpeg -nostdin -n -f lavfi -i 'color=c=0x101020:s=320x180:r=10:d=4.3' \
-f lavfi -i 'sine=frequency=660:sample_rate=48000:duration=4.3' \
-c:v libx264 -threads 2 -pix_fmt yuv420p -c:a aac input.mp4
Save these cues as subtitles.srt:
1
00:00:00,600 --> 00:00:01,400
First Ruby caption
2
00:00:02,100 --> 00:00:03,200
Second cue: café
3
00:00:04,000 --> 00:00:04,200
Final cue
ruby add_subtitles.rb input.mp4 subtitles.srt output_with_subs.mp4
Open output_with_subs.mp4 in your video player. For the test clip, the first caption appears from
0.6 to 1.4 seconds, the second from 2.1 to 3.2 seconds, and the last from 4.0 to 4.2 seconds.
The gaps should contain no captions, and the 4.3-second video should still have its tone. Checking
that it plays is only the first check: confirm the text, placement, and timing against the SRT.
Manipulating SRT files with the SRT gem
If every caption appears 2.5 seconds too early, delay all cues by that amount. Save this as
shift_subtitles.rb:
require_relative 'subtitle_tools'
abort 'Usage: ruby shift_subtitles.rb <subtitles.srt> <new-output.srt>' unless ARGV.length == 2
begin
subtitles = SubtitleTools.load_srt(ARGV[0])
subtitles.timeshift(all: '+2.5s')
SubtitleTools.write_srt(subtitles, ARGV[1])
puts 'Subtitles shifted by 2.5 seconds'
rescue StandardError
warn 'Subtitle shift failed. Check the SRT input and use a new output path.'
exit 1
end
ruby shift_subtitles.rb subtitles.srt modified.srt
The test cues become 3.1–3.9, 4.6–5.7, and 6.5–6.7 seconds; their text stays the same. A shift
changes the subtitle timeline, so these later cues extend beyond the short test video's end.
Use the unshifted SRT when burning that clip. The gem's documented
timeshift method takes an options hash.
Adjust the delay for your video; this example applies the same offset to every cue and does not
correct progressive drift.
Converting subtitle formats
To produce a WebVTT sidecar with the original cue timing, save this as convert_subtitles.rb:
require_relative 'subtitle_tools'
abort 'Usage: ruby convert_subtitles.rb <subtitles.srt> <new-output.vtt>' unless ARGV.length == 2
begin
SubtitleTools.convert_to_vtt(*ARGV)
puts 'WebVTT subtitles saved'
rescue StandardError
warn 'Subtitle conversion failed. Check the SRT input, output path, and FFmpeg installation.'
exit 1
end
ruby convert_subtitles.rb subtitles.srt subtitles.vtt
For the sample SRT, subtitles.vtt contains:
WEBVTT
00:00.600 --> 00:01.400
First Ruby caption
00:02.100 --> 00:03.200
Second cue: café
00:04.000 --> 00:04.200
Final cue
WebVTT uses a header and a period as the millisecond separator. Conversion creates a separate file; it does not add a track to the MP4. To convert the shifted SRT instead:
ruby convert_subtitles.rb modified.srt shifted.vtt
Error handling for subtitle integration
Each CLI returns a nonzero status on a helper failure. The loader refuses missing files, SRT larger than 1 MiB, invalid UTF-8, parser-reported errors, empty caption text, and nonpositive cue durations. The gem can record parsing errors on individual lines, so checking only for a thrown exception would miss them.
Every output must be new, and its parent directory must already exist. A rerun refuses an existing
destination and keeps its bytes. Staging beside the destination and publishing with a hard link
avoids exposing a partially written result during ordinary failures. A forced process kill can
leave a .subtitles-… directory; inspect it before removing it.
FFmpeg can recover some damaged media and still return success. In a test with a damaged late AAC packet, it produced a playable MP4 with shortened audio. Metadata and successful decoding cannot prove that an unknown source was intact. Compare the result's duration, audio, and final frames with your known input. FFmpeg diagnostics may appear in local logs; keep those separate from messages sent to web clients.
Supported subtitle formats
These scripts accept UTF-8 SRT and produce SRT, WebVTT, or a burned-in MP4. The examples cover
plain dialogue cues. ASS styling, WebVTT positioning, and other subtitle formats require a different
input path; renaming them to .srt does not convert them.
Practical example: batch processing videos with subtitles
For a directory of MP4 files, reuse the same helper sequentially. Save this as batch_subtitles.rb.
It pairs lesson.mp4 with lesson.srt; it also accepts an uppercase .MP4 extension and skips
symlink entries. The output directory must be new.
require_relative 'subtitle_tools'
def batch_process_videos(video_dir, subtitle_dir, output_dir)
video_files = Dir.children(video_dir).sort.filter_map do |name|
path = File.join(video_dir, name)
path if File.extname(name).downcase == '.mp4' && File.file?(path) && !File.symlink?(path)
end
raise ArgumentError, 'No MP4 files found' if video_files.empty?
Dir.mkdir(output_dir, 0700)
failures = 0
video_files.each do |video|
basename = File.basename(video, File.extname(video))
begin
SubtitleTools.burn(video, File.join(subtitle_dir, "#{basename}.srt"),
File.join(output_dir, "#{basename}_subtitled.mp4"))
puts "Converted #{basename}"
rescue StandardError
failures += 1
warn "Conversion failed for #{basename}"
end
end
failures.zero? ? 0 : 1
end
abort 'Usage: ruby batch_subtitles.rb <videos> <subtitles> <new-output-directory>' unless ARGV.length == 3
begin
exit batch_process_videos(*ARGV)
rescue StandardError
warn 'Cannot prepare batch. Check input directories and use a new output directory.'
exit 1
end
ruby batch_subtitles.rb videos captions subtitled
Create videos and captions first and put matching files in them. Missing or invalid subtitles
count as failures, and the message identifies the video's basename. Remaining files are still
attempted and successful outputs are retained. Exit status zero means every selected video
converted; status one means preparation or at least one conversion failed. For a retry, use a new
output directory so completed files remain available.
For a managed alternative, see Transloadit's 🤖 /video/subtitle Robot and Video Encoding service.
