Automatiza momentos destacados de video con Ruby y FFmpeg
Encontrar momentos útiles en grabaciones largas lleva tiempo. Ruby puede automatizar la detección de cambios de escena, extraer clips candidatos y unirlos en un montaje de revisión con FFmpeg. Este método heurístico se basa en cambios visuales; no es un sistema que comprenda qué momentos son importantes.
Introducción a la automatización de momentos destacados de video
El flujo de trabajo detecta cambios grandes entre fotogramas adyacentes, selecciona marcas de tiempo suficientemente separadas y genera clips cortos a su alrededor mediante encoding. Un corte, un destello o una transición de diapositiva pueden ser candidatos útiles, pero un gol o una reflexión expresada verbalmente pueden no coincidir con un corte de escena. Revisa el montaje generado antes de publicarlo.
Configuración de Ruby y FFmpeg
Usa Bash en Linux con Ruby, FFmpeg y su herramienta complementaria ffprobe en PATH. Este tutorial se
probó con Ruby 3.4.10 y FFmpeg/ffprobe 9.0.1. Estas son las versiones probadas, no requisitos exactos
de instalación. Usa una versión con los parches actuales de Ruby 3.4 de la
guía de instalación de Ruby y consulta su
estado de mantenimiento.
En Ubuntu/Debian, instala FFmpeg y ffprobe juntos:
sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
El ejemplo usa las bibliotecas estándar de Ruby sin una biblioteca de terceros que encapsule FFmpeg. Antes de guardar los scripts, comprueba que Ruby pueda cargar esas bibliotecas e inspecciona los filtros y codificadores de tu compilación de FFmpeg:
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE ruby -v &&
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE ruby -e "require 'fileutils'; require 'json'; require 'open3'" &&
ffmpeg -version && ffprobe -version && ffmpeg -filters && ffmpeg -encoders
Compatibilidad de versiones
Tu compilación de FFmpeg necesita los filtros select, metadata, atrim,
asetpts y concat, y los codificadores libx264 y aac. El mismo procedimiento también funciona
con FFmpeg 6.1.1; usa un paquete con los parches correspondientes para tu distribución. La invocación
elimina los ajustes heredados del cargador de Ruby y de Bundler, por lo que ejecutarla desde un
proyecto Ruby que contenga estos scripts no selecciona las gemas de ese proyecto.
Elige un video local que sepas que funciona correctamente, con un tamaño de fotograma fijo y color SDR. El script selecciona su primera pista de video y, si existe, su primera pista de audio. El audio debe cubrir los intervalos de video seleccionados. Las subidas no confiables, las grabaciones dañadas y los archivos que cambian de resolución durante la reproducción necesitan una validación independiente y un proceso de trabajo aislado; esta CLI no proporciona ese aislamiento. FFmpeg puede recuperarse de un paquete dañado, mostrar una advertencia y aun así devolver un resultado exitoso. Completar un lote no valida la integridad del archivo de origen; revisa los diagnósticos y los cortes generados.
Extracción de momentos clave mediante filtros de FFmpeg
La puntuación de escena de FFmpeg estima las diferencias visuales
entre fotogramas. Para una prueba manual en un directorio de trabajo nuevo, este comando escribe
las marcas de tiempo de los fotogramas seleccionados en timestamps.txt:
ffmpeg -nostdin -n -threads 2 -filter_threads 1 -i input.mp4 -map 0:v:0 \
-vf "select='gt(scene,0.3)',metadata=print:file=timestamps.txt" \
-an -f null -
Mantén la detección de escenas y la extracción de clips en la línea de tiempo relativa a la entrada
de FFmpeg. No restablezcas las marcas de tiempo del flujo de video de forma independiente: el audio
puede comenzar antes del primer fotograma de video. Tanto la detección como la extracción
seleccionan explícitamente la primera pista de video. El archivo de texto del filtro es
independiente de las comprobaciones habituales de archivos de salida de FFmpeg: no reutilices un
timestamps.txt existente.
La implementación en Ruby crea un directorio privado para la tarea que lo contiene.
Implementación de scripts de Ruby para la automatización
Guarda esta clase como highlight_extractor.rb. Mantiene fijos los nombres de los archivos generados y pasa las rutas
de entrada como argumentos separados del proceso, de modo que las rutas no forman parte de la
sintaxis del shell ni de los filtros. Si una etapa falla, se lanza un error en lugar de generar
silenciosamente un informe de lote exitoso.
require 'fileutils'
require 'json'
require 'open3'
class HighlightExtractor
def initialize(input_file, output_dir)
@input = File.expand_path(input_file)
raise ArgumentError, 'Input must be a regular file' unless File.file?(@input)
@output = File.expand_path(output_dir)
end
def generate(threshold: 0.3, clip_duration: 5.0, pre_roll: 2.0, min_gap: 10.0)
unless threshold.finite? && (0.0..1.0).cover?(threshold) &&
clip_duration.finite? && clip_duration > 0 &&
pre_roll.finite? && pre_roll >= 0 && min_gap.finite? && min_gap >= clip_duration
raise ArgumentError, 'Invalid scene or clip settings'
end
# Refuse earlier job outputs before taking ownership of this directory.
Dir.mkdir(@output, 0700)
begin
duration = video_duration
selected = detect_scene_changes(threshold).each_with_object([]) do |time, result|
next if time >= duration
result << time if result.empty? || time - result.last >= min_gap
end
# Cap output count so a noisy video cannot create an unlimited clip set.
lengths = []
clips = selected.first(20).each_with_index.map do |time, index|
start = [time - pre_roll, 0.0].max
length = [clip_duration, duration - start].min
lengths << length
name = format('highlight_%03d.mp4', index + 1)
execute('-i', @input, '-ss', start.to_s, '-t', length.to_s,
'-map', '0:v:0', '-map', '0:a:0?', '-c:v', 'libx264', '-preset', 'fast',
'-crf', '23', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-threads', '2', name)
name
end
create_highlight_compilation(clips, lengths) unless clips.empty?
clips.length
rescue StandardError
FileUtils.remove_entry(@output)
raise
end
end
private
def video_duration
output, status = Open3.capture2('ffprobe', '-v', 'error', '-show_entries',
'format=duration:stream=codec_type', '-of', 'json', @input)
raise IOError, 'Cannot inspect input video' unless status.success?
info = JSON.parse(output)
@has_audio = info.fetch('streams').any? { |stream| stream.fetch('codec_type') == 'audio' }
duration = Float(info.fetch('format').fetch('duration'))
raise ArgumentError, 'Invalid video duration' unless duration.finite? && duration > 0
duration
end
def execute(*arguments)
unless system('ffmpeg', '-nostdin', '-n', '-threads', '2', '-filter_threads', '1',
'-filter_complex_threads', '1',
*arguments, chdir: @output)
raise IOError, 'FFmpeg processing failed'
end
end
def detect_scene_changes(threshold)
execute('-i', @input, '-map', '0:v:0', '-vf',
"select='gt(scene,#{threshold})',metadata=print:file=timestamps.txt",
'-an', '-f', 'null', '-')
File.foreach(File.join(@output, 'timestamps.txt')).each_with_object([]) do |line, times|
match = line.match(/pts_time:([0-9.eE+-]+)/)
times << Float(match[1]) if match
end.uniq.sort
end
def create_highlight_compilation(clips, lengths)
list = clips.each_with_index.map do |name, index|
"file '#{name}'\nduration #{lengths[index]}\n"
end.join
File.write(File.join(@output, 'filelist.txt'), list)
arguments = ['-copyts', '-f', 'concat', '-safe', '1', '-i', 'filelist.txt']
if @has_audio
clips.each { |name| arguments.concat(['-i', name]) }
filters = clips.each_index.map do |index|
"[#{index + 1}:a:0]atrim=duration=#{lengths[index]},asetpts=PTS-STARTPTS[a#{index}]"
end
inputs = clips.each_index.map { |index| "[a#{index}]" }.join
filters << "#{inputs}concat=n=#{clips.length}:v=0:a=1[audio]"
arguments.concat(['-filter_complex', filters.join(';'), '-map', '0:v:0', '-map',
'[audio]', '-c:v', 'copy', '-c:a', 'aac'])
else
arguments.concat(['-map', '0:v:0', '-c:v', 'copy'])
end
execute(*arguments, 'highlight_reel.mp4')
end
end
El demultiplexor concat necesita flujos y bases de tiempo coincidentes. Estos clips comparten un archivo de origen y los ajustes del codificador; la lista contiene solo los nombres de archivo generados, sin rutas, por lo que el modo de rutas seguras permanece activado. Las duraciones explícitas sitúan cada clip de video en la línea de tiempo del montaje. El montaje copia el video ya procesado mediante encoding. Cuando hay audio, decodifica cada clip por separado, recorta el audio a la duración solicitada del clip y lo une con el filtro concat antes de volver a aplicar encoding en AAC. Esto evita que el relleno añadido por el codificador de cada clip se acumule como retraso del audio. Este montaje de revisión no es un máster de audio sin interrupciones ni una herramienta para concatenar archivos sin relación entre sí. Escucha las uniones antes de publicarlo.
El codificador libx264 usa CRF 23 como objetivo de calidad, sin un límite de tasa de bits.
Un valor menor suele conservar más detalle y aumentar el tamaño del archivo. El ajuste
preestablecido fast controla el esfuerzo de encoding, no qué escenas se seleccionan.
La opción -ss del lado de salida decodifica y descarta el metraje
anterior al tiempo de inicio de cada clip. Este método directo de decodificación no depende de
índices utilizables para desplazarse por el video, pero repite trabajo para los momentos destacados
que aparecen más adelante. Para grabaciones largas con índices, mide el rendimiento del
desplazamiento preciso desde el lado de entrada y verifica los límites de los clips y los desfases
entre audio y video antes de cambiar ese comportamiento.
Guarda este script de invocación junto a la clase como generate_highlights.rb:
require_relative 'highlight_extractor'
abort 'Usage: ruby generate_highlights.rb <input-video> <new-output-directory>' unless ARGV.length == 2
begin
count = HighlightExtractor.new(*ARGV).generate
puts(count.zero? ? 'No scene changes selected' : "Created #{count} candidate highlights")
rescue StandardError
warn 'Highlight generation failed. Check the input, destination, and FFmpeg installation.'
exit 1
end
Ejecútalo desde tu terminal:
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE \
ruby generate_highlights.rb my_video.mp4 my_highlights_folder
Mantén ambos scripts juntos y sustituye my_video.mp4 por tu ruta de entrada. Pon entre comillas las rutas
que contengan espacios. Una ejecución exitosa informa la cantidad de candidatos y escribe
highlight_001.mp4, los siguientes clips numerados y highlight_reel.mp4 dentro de my_highlights_folder. Abre el montaje en tu
reproductor de video. Cada clip predeterminado comienza 2 segundos antes del corte seleccionado,
sin adelantarse al inicio de la entrada, y dura hasta 5 segundos. Los cortes se seleccionan en
orden cronológico con una separación de al menos 10 segundos, hasta un máximo de 20 clips.
Si el directorio de salida ya existe, se rechaza y permanece intacto; elige un nombre nuevo para volver a intentarlo. Un error de procesamiento después de crear el directorio de la tarea elimina las salidas parciales de esa tarea. Corrige la entrada o la instalación y luego repite el mismo comando. Un video válido sin cambios de escena seleccionados muestra No scene changes selected, conserva su archivo de marcas de tiempo y termina correctamente sin clips ni montaje. Los diagnósticos locales de FFmpeg pueden incluir nombres de archivo; evita incluir rutas sensibles en los registros compartidos.
Optimización del rendimiento
Ejecuta primero una grabación corta y representativa, y revisa los cortes resultantes. El límite de veinte clips restringe los clips generados, no la duración de la entrada, el análisis completo de escenas ni el tamaño de la salida. Deja suficiente espacio en disco tanto para los clips individuales como para el montaje. Aquí se limita la cantidad de hilos del decodificador y del codificador; ejecutar varias tareas sigue multiplicando su uso de CPU y almacenamiento.
Optimización de la selección de momentos destacados
Un umbral de escena menor selecciona más cambios, incluido el ruido; uno mayor puede pasar por
alto transiciones sutiles. El ajuste min_gap mantiene las marcas de tiempo seleccionadas separadas por
al menos esa cantidad de segundos y debe ser como mínimo clip_duration. La selección es cronológica;
no clasifica los momentos según lo emocionantes que sean. Ajusta el tiempo previo y la duración
tras revisar tu contenido.
Ejemplos prácticos y casos de uso
Los candidatos basados en cambios de escena pueden ayudar a revisar entrevistas editadas, transiciones de diapositivas y metraje de montaje. No supongas que la detección de escenas reconoce goles, victorias, hablantes específicos o conclusiones expresadas verbalmente. Esas tareas requieren señales adicionales y una validación independiente. El montaje generado es un punto de partida para la revisión editorial.
Conclusión y posibles mejoras
Si el montaje omite momentos que necesitas, reducir el umbral de escena podría limitarse a añadir más ruido visual. Añade análisis de audio o un detector de eventos validado cuando tu selección dependa del habla o de la actividad dentro de un plano sin cambios.
Para el procesamiento gestionado, el servicio de encoding de video de Transloadit ofrece Robots basados en FFmpeg para encoding y flujos de trabajo multimedia.
