Subtitula videos en Ruby con herramientas de código abierto
Usa Ruby y FFmpeg para incrustar los subtítulos de un archivo SRT existente en los fotogramas de
un video, con streamio-ffmpeg para los metadatos y la gema
srt para los tiempos de las entradas. Los scripts siguientes producen
un MP4 con subtítulos, un SRT retrasado 2,5 segundos y un archivo WebVTT independiente. Trabajan
con los subtítulos que tú proporcionas; no transcriben el audio.
Por qué son importantes los subtítulos
Los subtítulos incrustados siguen siendo visibles en reproductores que no pueden cargar una pista de subtítulos. Se convierten en parte de la imagen, por lo que quienes ven el video no pueden desactivarlos ni seleccionar otro idioma. Conserva el video y el archivo de subtítulos originales si también necesitas una pista seleccionable en el reproductor.
Herramientas de código abierto en Ruby para subtítulos
Las dos gemas cumplen funciones distintas:
- streamio-ffmpeg lee los metadatos del video con FFprobe. El código auxiliar la usa para comprobar que haya un flujo de video antes de la codificación.
- srt analiza las entradas SRT, desplaza sus marcas de tiempo y escribe SRT. La conversión a WebVTT que se muestra a continuación llama a FFmpeg directamente desde Ruby.
Estas son ramas de versiones antiguas: streamio-ffmpeg 3.0.2 se publicó en 2016 y
srt 0.1.5 en 2021, según sus páginas de
RubyGems sobre
versiones. Su antigüedad hace útil contar con una combinación
probada; las notas de compatibilidad de la biblioteca de integración no garantizan la
compatibilidad con las versiones actuales de FFmpeg.
Añade subtítulos con streamio-ffmpeg
Los ejemplos se probaron en Linux con Ruby 3.4.10 y FFmpeg/FFprobe 9.0.1. Son versiones probadas,
no versiones mínimas obligatorias; puedes probar versiones de mantenimiento más recientes de
esas ramas con las comprobaciones siguientes. Instala Ruby 3.4 y
FFmpeg con FFprobe por separado. Necesitas los codificadores
libx264, AAC y WebVTT, el
filtro subtitles de libass y una fuente como
DejaVu Sans que incluya los caracteres del texto de tus subtítulos.
Usa Bash en un directorio nuevo con permisos de escritura, con RUBYOPT y
BUNDLE_GEMFILE sin definir. Ejecuta allí esta configuración y mantén abierta la
misma sesión de shell para los comandos posteriores. La instalación y los metadatos de las gemas
quedan en este directorio. El destino de instalación explícito también evita una instalación de
usuario configurada en RubyGems.
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
)
La lista incluye la dependencia multi_json de la biblioteca de integración,
por lo que se desactiva la instalación de dependencias y se activa cada versión seleccionada en
el código auxiliar siguiente. Si falla una descarga, vuelve a ejecutar este bloque en el mismo
directorio después de resolver el problema de conexión. Comprueba la compilación instalada de
FFmpeg antes de crear los archivos de entrada:
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
La ayuda debe describir cada filtro o codificador mencionado. Un mensaje «unknown» significa que necesitas otra compilación, incluso si el comando de ayuda termina correctamente.
Guarda esta implementación compartida como subtitle_tools.rb. Todos los ejemplos
siguientes la reutilizan. El directorio temporal mantiene el nombre del archivo de subtítulos
fuera de la sintaxis de filtros de FFmpeg. La publicación del resultado utiliza un enlace físico
en el sistema de archivos de salida; ese sistema debe admitir enlaces físicos.
La implementación de transcode de la
biblioteca de integración valida que los metadatos de salida sean legibles sin comprobar el
estado de salida de FFmpeg. Por eso, el código auxiliar usa FFMPEG::Movie para
los metadatos y llama a FFmpeg con
system de Ruby, comprobando su estado
antes de publicar un resultado.
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
El procesamiento por lotes siguiente llama a este código auxiliar de forma secuencial. Vuelve a codificar el video seleccionado a H.264 y el audio, cuando lo hay, a AAC. Se aplica la selección de flujos predeterminada de FFmpeg; no conserva todas las pistas de una fuente con varias pistas. Usa archivos multimedia locales verificados: estos fragmentos no proporcionan un entorno aislado para las subidas.
Guarda esta interfaz de línea de comandos junto al código anterior como 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
Usa tu MP4 y un archivo SRT en UTF-8, o genera este breve video de prueba. El comando rechaza un
input.mp4 existente:
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
Guarda estas entradas como 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
Abre output_with_subs.mp4 en tu reproductor de video. En el clip de prueba, el primer
subtítulo aparece de 0,6 a 1,4 segundos, el segundo de 2,1 a 3,2 segundos y el último de 4,0 a
4,2 segundos. Los intervalos deben estar libres de subtítulos y el video de 4,3 segundos debe
conservar su tono. Comprobar que se reproduce es solo la primera comprobación: confirma el texto,
la posición y los tiempos comparándolos con el SRT.
Manipula archivos SRT con la gema SRT
Si todos los subtítulos aparecen 2,5 segundos antes de tiempo, retrasa todas las entradas esa
cantidad. Guarda esto como 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
Las entradas de prueba pasan a 3,1–3,9, 4,6–5,7 y 6,5–6,7 segundos; su texto no cambia. Un
desplazamiento modifica la línea de tiempo de los subtítulos, por lo que estas entradas posteriores
se extienden más allá del final del breve video de prueba. Usa el SRT sin desplazar al incrustar
los subtítulos en ese clip. El
método timeshift documentado de la gema
recibe un hash de opciones. Ajusta el retraso para tu video; este ejemplo aplica el mismo
desplazamiento a todas las entradas y no corrige una desincronización progresiva.
Convierte formatos de subtítulos
Para producir un archivo WebVTT independiente con los tiempos originales de las entradas, guarda
esto como 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
Para el SRT de ejemplo, subtitles.vtt contiene:
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 usa una cabecera y un punto como separador de milisegundos. La conversión crea un archivo independiente; no añade una pista al MP4. Para convertir en su lugar el SRT desplazado:
ruby convert_subtitles.rb modified.srt shifted.vtt
Manejo de errores en la integración de subtítulos
Cada CLI devuelve un estado distinto de cero si falla el código auxiliar. El cargador rechaza archivos inexistentes, archivos SRT de más de 1 MiB, texto UTF-8 no válido, errores reportados por el analizador, texto de subtítulos vacío y duraciones de entradas menores o iguales a cero. La gema puede registrar errores de análisis en líneas individuales, por lo que comprobar únicamente si se lanza una excepción no los detectaría.
Cada archivo de salida debe ser nuevo y su directorio padre debe existir. Una nueva ejecución
rechaza un destino existente y conserva sus bytes. Preparar el resultado junto al destino y
publicarlo con un enlace físico evita exponer un resultado escrito parcialmente durante fallos
habituales. La terminación forzada del proceso puede dejar un directorio
.subtitles-…; revísalo antes de eliminarlo.
FFmpeg puede recuperar algunos archivos multimedia dañados y aun así indicar que la operación terminó correctamente. En una prueba con un paquete AAC dañado cerca del final, produjo un MP4 reproducible con el audio acortado. Los metadatos y una decodificación correcta no pueden demostrar que una fuente desconocida estaba intacta. Compara la duración, el audio y los fotogramas finales del resultado con los del archivo de entrada que conoces. Los diagnósticos de FFmpeg pueden aparecer en los registros locales; mantenlos separados de los mensajes enviados a los clientes web.
Formatos de subtítulos compatibles
Estos scripts aceptan SRT en UTF-8 y producen SRT, WebVTT o un MP4 con subtítulos incrustados.
Los ejemplos cubren entradas de diálogo sin formato. Los estilos ASS, el posicionamiento WebVTT
y otros formatos de subtítulos requieren una vía de entrada diferente; cambiarles el nombre a
.srt no los convierte.
Ejemplo práctico: procesamiento por lotes de videos con subtítulos
Para un directorio de archivos MP4, reutiliza el mismo código auxiliar de forma secuencial.
Guarda esto como batch_subtitles.rb. Empareja lesson.mp4 con
lesson.srt; también acepta la extensión .MP4 en
mayúsculas y omite las entradas que sean enlaces simbólicos. El directorio de salida debe ser nuevo.
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
Crea primero videos y captions y coloca en ellos
los archivos correspondientes. Los subtítulos inexistentes o no válidos cuentan como fallos, y
el mensaje identifica el nombre base del video. Se sigue intentando procesar los demás archivos
y se conservan los resultados correctos. El estado de salida cero significa que todos los videos
seleccionados se convirtieron; el estado uno significa que falló la preparación o al menos una
conversión. Para volver a intentarlo, usa un directorio de salida nuevo para que los archivos
completados sigan disponibles.
Para una alternativa gestionada, consulta el 🤖 /video/subtitle Robot de Transloadit y su servicio de encoding de video.
