Scripts Bash para Unir Video y Audio con FFmpeg

Scripts Bash para Unir Video y Audio con FFmpeg

Aprende a automatizar la fusión de videoclases con sus pistas de audio usando FFmpeg y Bash. Desde scripts básicos hasta una versión robusta, genérica y optimizada con buenas prácticas.

FECHA:
ACTUALIZADO:
AUTOR:Jorge Beneyto Castelló
LECTURA:11 MINUTOS DE LECTURA

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:

ConceptoQué significa
CodecAlgoritmo que comprime/descomprime video (H.264) o audio (AAC, MP3). FFmpeg soporta decenas
ContenedorEl 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:

  1. Extrae el número de tema del nombre del archivo con grep -oP 'T\d+'. Si el video se llama Psicobio 1 T03.mp4, extrae el 3. Este número es la clave para emparejar video y audio.
  2. Construye las rutas de audio esperadas concatenando ese número en dos patrones posibles (biolo1 o biolo2). Solo una existirá.
  3. Copia video y audio a una carpeta temporal dentro de ./UNIDOS. Usa realpath para resolver rutas absolutas y evitar problemas si el script se ejecuta desde otro directorio.
  4. Construye el comando ffmpeg como una cadena de texto y lo ejecuta con eval. Esto es peligroso (lo explico abajo).
  5. Si ffmpeg termina bien, mueve el resultado a ./UNIDOS y elimina la carpeta temporal.

🔴 Problemas de estos scripts

ProblemaRiesgoPor qué
eval para ejecutar ffmpegAlto — si un nombre de archivo contiene ;, `, $, se inyecta código arbitrarioeval trata la cadena como código Bash, no como argumentos
Sin set -eMedio — si ffmpeg falla, el script sigue ejecutando el resto como si nadaEl if para el código de salida ayuda, pero no cubre todos los casos
Código duplicado x3Alto — si hay que cambiar algo, hay que tocarlo en tres sitiosLa lógica de copia, ffmpeg y limpieza es idéntica en los tres scripts
[ -f "$v_file" ] sin comillas en algunos sitiosBajo — si hay espacios en la ruta, el test fallaEl shell parte el argumento por espacios; con comillas lo trata como una sola pieza
Sin trap para limpiezaMedio — si el script se corta a mitad, quedan carpetas temporales huérfanasNo 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.
  • -shortest en 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_fusionar gené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:

  1. set -euo pipefail — siempre. No negociable.
  2. Arrays para comandos — nunca concatenes cadenas con argumentos.
  3. Comillas dobles en todas las variables — "$var" siempre, incluso en tests [[ -f "$var" ]].
  4. Validación al inicio — comprueba que los directorios existen, que hay archivos, que ffmpeg está instalado.
  5. Logging — redirige la salida a un archivo (&>> script.log). Te ahorrará horas de depuración.
  6. Progreso visible — muestra el contador [3/40] o usa pv.
  7. trap para limpiar — sobre todo si creas archivos temporales.
  8. Prueba con un archivo primero — set -e no 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 ./VIDEOS y su audio a ./Audios, ejecuta buscar_y_fusionar y 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 .mp4 de salida en ./UNIDOS y 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:

  1. Más comandos de FFmpeg y yt-dlp — descargas, formatos, presets y casos de uso: Guía de comandos de yt-dlp y FFmpeg.
  2. Instalar FFmpeg + yt-dlp con Docker — un pipeline reproducible sin tocar tu sistema: Instalación de FFmpeg + yt-dlp.
  3. Herramientas de terminal — comandos que acortan el trabajo diario: Comandos de Terminal que Uso.
  4. Buenas prácticas en Bash — lleva tus scripts a un nivel más robusto y seguro.

🔗 Enlaces y recursos

COMPARTIR:
COMENTARIOS:

📋 Contenido