Si alguna vez has tenido que emparejar decenas de videoclases con sus pistas de audio grabadas por separado, sabes que hacerlo a mano es una tortura. Abrir cada video en un editor, añadir la pista, exportar… repetir 40 veces.
Los tres scripts que ves abajo fueron mi primera solución. Funcionan, pero tienen problemas: código duplicado, uso peligroso de eval, y cero tolerancia a errores. En este post las analizo, las unifico en un script genérico, y termino con una versión lista para producción.
🤔 ¿Qué es FFmpeg y por qué debería importarte?
FFmpeg es la navaja suiza del procesamiento audiovisual. No es una app con botones, es una herramienta de terminal que puede leer, transformar y escribir prácticamente cualquier formato de video, audio o imagen. Está detrás de VLC, OBS, YouTube, y la mayoría de cosas que reproduces o grabas.
Conceptos clave que uso en este post:
| Concepto | Qué significa |
|---|---|
| Codec | Algoritmo que comprime/descomprime video (H.264) o audio (AAC, MP3). FFmpeg soporta decenas |
| Contenedor | El archivo que ves (.mp4, .mkv, .avi). Envuelve pistas de video + audio + subtítulos |
Stream copy (-c:v copy) | Copia el video sin recodificar. Rápido, sin pérdida de calidad, no toca los píxeles |
Re-encode (-c:a aac) | Vuelve a codificar el audio. Necesario si el formato original no es compatible con el contenedor |
En los scripts de abajo usamos stream copy para video (el archivo original se mantiene intacto) y re-encode a AAC para el audio (porque el audio suele venir en formatos que MP4 no acepta directamente).
Referencia: Documentación oficial de FFmpeg. También tengo una guía más completa sobre FFmpeg y yt-dlp en el blog.
📜 Los scripts originales (y dónde fallan)
Los tres hacen lo mismo: buscar un video .mp4, encontrar su audio correspondiente por número de tema, copiar ambos a una carpeta temporal y fusionarlos con ffmpeg. La única diferencia es cómo localizan el archivo de audio.
#!/bin/bash
videos_folder="./VIDEOS"
audios_folder="./Audios"
unidos_folder="./UNIDOS"
for video_file in "$videos_folder"/*.mp4; do
t_number=$(echo "$video_file" | grep -oP 'T\d+' | cut -c2-)
audio_file_1="$audios_folder/biolo1/Psicobio 1 T${t_number}-audio.mp4"
audio_file_2="$audios_folder/biolo2/Psicobio 2 T${t_number}-audio.mp4"
if [ -f "$video_file" ]; then
if [ -f "$audio_file_1" ]; then
audio_file="$audio_file_1"
elif [ -f "$audio_file_2" ]; then
audio_file="$audio_file_2"
else
echo "No se encontró el archivo de audio correspondiente para $video_file"
continue
fi
else
echo "No se encontró el archivo de video $video_file"
continue
fi
mkdir -p "$unidos_folder"
output_folder="$unidos_folder/$(basename "$video_file" .mp4)"
mkdir -p "$output_folder"
v_file="$output_folder/01.mp4"
a_file="$output_folder/01-audio.mp4"
if [ ! -f "$v_file" ] && [ ! -f "$a_file" ]; then
cp "$(realpath "$video_file")" "$v_file"
cp "$(realpath "$audio_file")" "$a_file"
echo "Unión completa para $video_file"
else
echo "El archivo \"$v_file\" ya existe. No se hizo nada."
fi
if [ -f "$v_file" ] && [ -f "$a_file" ]; then
output_folder_absolute=$(realpath "$output_folder")
ar_videos_command="ffmpeg -i \"$output_folder_absolute/01.mp4\" -i \"$output_folder_absolute/01-audio.mp4\" -c:v copy -c:a aac -strict experimental \"$output_folder_absolute/001.mp4\""
eval "$ar_videos_command"
ffmpeg_exit_code=$?
if [ $ffmpeg_exit_code -eq 0 ] && [ -f "$output_folder/001.mp4" ]; then
mv "$output_folder/001.mp4" "$output_folder/$(basename "$output_folder").mp4"
echo "Conversión completa para la carpeta $(basename "$output_folder")"
mv "$output_folder/$(basename "$output_folder").mp4" "$unidos_folder"
rm -r "$output_folder"
echo "Movido y borrado correctamente."
else
echo "Error: No se generó el archivo de salida correctamente."
fi
else
echo "No se encontraron archivos de video y audio en la carpeta $output_folder"
fi
done
Lo que hace paso a paso:
- Extrae el número de tema del nombre del archivo con
grep -oP 'T\d+'. Si el video se llamaPsicobio 1 T03.mp4, extrae el3. Este número es la clave para emparejar video y audio. - Construye las rutas de audio esperadas concatenando ese número en dos patrones posibles (
biolo1obiolo2). Solo una existirá. - Copia video y audio a una carpeta temporal dentro de
./UNIDOS. Usarealpathpara resolver rutas absolutas y evitar problemas si el script se ejecuta desde otro directorio. - Construye el comando ffmpeg como una cadena de texto y lo ejecuta con
eval. Esto es peligroso (lo explico abajo). - Si ffmpeg termina bien, mueve el resultado a
./UNIDOSy elimina la carpeta temporal.
🔴 Problemas de estos scripts
| Problema | Riesgo | Por qué |
|---|---|---|
eval para ejecutar ffmpeg | Alto — si un nombre de archivo contiene ;, `, $, se inyecta código arbitrario | eval trata la cadena como código Bash, no como argumentos |
Sin set -e | Medio — si ffmpeg falla, el script sigue ejecutando el resto como si nada | El if para el código de salida ayuda, pero no cubre todos los casos |
| Código duplicado x3 | Alto — si hay que cambiar algo, hay que tocarlo en tres sitios | La lógica de copia, ffmpeg y limpieza es idéntica en los tres scripts |
[ -f "$v_file" ] sin comillas en algunos sitios | Bajo — si hay espacios en la ruta, el test falla | El shell parte el argumento por espacios; con comillas lo trata como una sola pieza |
Sin trap para limpieza | Medio — si el script se corta a mitad, quedan carpetas temporales huérfanas | No hay ningún mecanismo que limpie si algo falla |
🎯 Script unificado y mejorado
En lugar de tener tres scripts que cambian solo en la ruta de los audios, he creado uno solo que acepta parámetros. Es más corto, más seguro y más fácil de mantener.
#!/bin/bash
set -euo pipefail
unir_video_audio() {
local video="$1"
local audio="$2"
local output_dir="$3"
local output_name
output_name="$(basename "$video" .mp4)"
mkdir -p "$output_dir/$output_name"
local temp_video="$output_dir/$output_name/video.mp4"
local temp_audio="$output_dir/$output_name/audio.mp4"
local output_file="$output_dir/$output_name/$output_name.mp4"
if [[ -f "$temp_video" || -f "$temp_audio" ]]; then
echo " ↪ Ya existe: saltando $output_name"
return 0
fi
cp "$(realpath "$video")" "$temp_video"
cp "$(realpath "$audio")" "$temp_audio"
ffmpeg -i "$temp_video" -i "$temp_audio" \
-c:v copy -c:a aac -shortest \
"$output_file" &>> "$output_dir/$output_name/ffmpeg.log"
mv "$output_file" "$output_dir/"
rm -rf "$output_dir/$output_name"
echo " ✅ $output_name fusionado correctamente"
}
buscar_y_fusionar() {
local videos_dir="$1"
local audios_dir="$2"
local output_dir="$3"
shift 3
local patrones_audio=("$@")
for video in "$videos_dir"/*.mp4; do
[[ -f "$video" ]] || continue
local t_number
t_number=$(echo "$video" | grep -oP '(?<=T)\d+') || {
echo " ⚠️ No se pudo extraer número de tema de: $(basename "$video")"
continue
}
local encontrado=false
for patron in "${patrones_audio[@]}"; do
local audio="${patron//\{numero\}/$t_number}"
if [[ -f "$audio" ]]; then
unir_video_audio "$video" "$audio" "$output_dir"
encontrado=true
break
fi
done
if ! $encontrado; then
echo " ❌ No se encontró audio para: $(basename "$video")"
fi
done
}
# ── Ejemplo: Psicobiología ──
buscar_y_fusionar \
"./VIDEOS" \
"./Audios" \
"./UNIDOS" \
"./Audios/biolo1/Psicobio 1 T{numero}-audio.mp4" \
"./Audios/biolo2/Psicobio 2 T{numero}-audio.mp4"
Qué cambia respecto al original:
set -euo pipefail— el script para en seco si algo falla, si una variable no está definida, o si un pipe se rompe. No más errores silenciosos.[[ ]]en vez de[ ]— más seguro, no parte palabras, soporta regex con=~.- Arrays para argumentos de ffmpeg — adiós a
eval. Si un nombre de archivo contiene;o$, no pasa nada porque Bash no interpreta los elementos de un array como código. -shortesten ffmpeg — si el audio es más largo que el video (o viceversa), ffmpeg corta por lo más corto en lugar de rellenar con silencio o dejar el video sin audio al final.&>> ffmpeg.log— toda la salida de ffmpeg se guarda en un log por si hay que depurar. Ya no depende de capturar la salida del terminal.- Patrones de audio configurables — pasas los patrones como argumentos,
{numero}se reemplaza automáticamente. El mismo script sirve para Psicobiología, Social, o cualquier otra asignatura. - Función
buscar_y_fusionargenérica — ahora es un solo script reutilizable en vez de tres copias casi iguales.
Para usarlo con la asignatura Social (que tiene un solo patrón de audio):
buscar_y_fusionar \
"./VIDEOCLASES SOCIAL" \
"./VIDEOCLASES SOCIAL" \
"./UNIDOS" \
"./VIDEOCLASES SOCIAL/Social T{numero}-audio.mp4"
🛠️ Optimizaciones y mejoras reales
La versión de arriba es correcta, pero se puede afinar aún más. Aquí van mejoras que he ido aplicando según necesitaba procesar más volumen.
Procesar en paralelo con xargs o parallel
FFmpeg solo usa un núcleo por instancia. Si tienes 40 videos en un procesador de 8 núcleos, estás desperdiciando el 87.5% de tu CPU. Con xargs -P lanzas varios procesos simultáneos:
# Procesar 4 videos a la vez
ls ./VIDEOS/*.mp4 | xargs -I {} -P 4 unir_video_audio {} "${audio_correspondiente}" ./UNIDOS
Si prefieres GNU Parallel (más potente): documentación oficial de GNU Parallel.
Extraer metadatos con ffprobe en lugar de grep
Si los archivos de video tienen metadatos incrustados (título, asignatura, número de tema), ffprobe los extrae sin depender del nombre del archivo. Esto es mucho más robusto si los nombres no siguen un patrón fijo:
t_number=$(ffprobe -v error \
-show_entries format_tags=title \
-of default=noprint_wrappers=1:nokey=1 "$video_file" \
| grep -oP '\d+$')
ffprobe viene incluido con FFmpeg. Referencia: documentación de ffprobe.
Validar archivos antes de procesar
Nada peor que procesar 20 minutos y que el último video esté corrupto. Una comprobación rápida al inicio ahorra frustraciones:
validar_archivo() {
local archivo="$1"
if ! ffprobe -v error -show_entries format=duration -of csv=p=0 "$archivo" > /dev/null 2>&1; then
echo " ⚠️ Archivo corrupto o inválido: $(basename "$archivo")"
return 1
fi
return 0
}
Mostrar progreso con pv o barra de progreso
Cuando procesas decenas de archivos, saber cuánto llevas y cuánto falta es esencial:
total=$(ls ./VIDEOS/*.mp4 | wc -l)
actual=0
for video in ./VIDEOS/*.mp4; do
((actual++))
echo "[$actual/$total] Procesando $(basename "$video")..."
# ... lógica de fusión ...
done
O si prefieres barras visuales: pv (pipe viewer) te da progreso, velocidad y ETA automáticamente.
trap para limpieza automática
Si el script se interrumpe (Ctrl+C, fallo de disco, etc.), trap garantiza que no queden archivos temporales:
cleanup() {
echo "🧹 Limpiando archivos temporales..."
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
Nota: EXIT se ejecuta tanto si el script termina bien como si falla o recibe una señal. Para acciones específicas de error usa trap cleanup ERR.
📋 Checklist para tu propio script
Antes de lanzar un script de procesamiento por lotes, repasa esta lista:
set -euo pipefail— siempre. No negociable.- Arrays para comandos — nunca concatenes cadenas con argumentos.
- Comillas dobles en todas las variables —
"$var"siempre, incluso en tests[[ -f "$var" ]]. - Validación al inicio — comprueba que los directorios existen, que hay archivos, que ffmpeg está instalado.
- Logging — redirige la salida a un archivo (
&>> script.log). Te ahorrará horas de depuración. - Progreso visible — muestra el contador
[3/40]o usapv. trappara limpiar — sobre todo si creas archivos temporales.- Prueba con un archivo primero —
set -eno perdona. Ejecuta el script en un subconjunto antes de lanzarlo contra 200 archivos.
🧪 Comprobar que funciona
- Prueba con un solo archivo: antes de lanzarlo contra 200 vídeos, copia un par a
./VIDEOSy su audio a./Audios, ejecutabuscar_y_fusionary comprueba que aparece✅ nombre fusionado correctamente. - Revisa el log: si algo falla, mira
out_dir/nombre/ffmpeg.log— ahí está la salida completa de ffmpeg para depurar. - Verifica el resultado: abre el
.mp4de salida en./UNIDOSy confirma que vídeo y audio van sincronizados y con la pista de audio presente.
🪜 Siguiente nivel
Ya automatizas la fusión de vídeo y audio en Bash con FFmpeg. El siguiente escalón:
- Más comandos de FFmpeg y yt-dlp — descargas, formatos, presets y casos de uso: Guía de comandos de yt-dlp y FFmpeg.
- Instalar FFmpeg + yt-dlp con Docker — un pipeline reproducible sin tocar tu sistema: Instalación de FFmpeg + yt-dlp.
- Herramientas de terminal — comandos que acortan el trabajo diario: Comandos de Terminal que Uso.
- Buenas prácticas en Bash — lleva tus scripts a un nivel más robusto y seguro.
🔗 Enlaces y recursos
- Documentación oficial de FFmpeg — todas las opciones explicadas
- FFmpeg Wiki: Stream copy — cuándo y por qué usar
-c copy - GNU Bash Manual — looping, parameter expansion, conditional constructs
- GNU Parallel — ejecución paralela en Bash
- Documentación de ffprobe — extraer metadatos y validar archivos
