Concatena videos en Scala con FFmpeg
Para unir clips exportados con los mismos ajustes, llama al demultiplexor concat de FFmpeg desde Scala y copia los flujos de video y audio en un único MP4. El programa siguiente verifica las entradas, las une en el orden de los argumentos y devuelve un estado de salida distinto de cero si falla la validación o FFmpeg. No permite sobrescribir un destino existente.
Configura tu entorno
Este tutorial usa Linux x86-64, Bash, OpenJDK 21.0.12.1, Scala CLI 1.17.1 y Scala 3.3.7. Las
verificaciones nativas de medios usan FFmpeg y ffprobe 9.0.1. Asegúrate de tener
java, ffmpeg, ffprobe,
curl y gzip
en tu PATH; los comandos para crear clips de muestra también necesitan
los codificadores libx264 y aac de FFmpeg.
Instala Scala
Usa la versión de Scala CLI para Linux
para mantener el lanzador dentro del directorio local de este ejemplo. Pega este bloque en Bash
desde un directorio donde puedas crear un proyecto nuevo. Si scala-video-concat ya
existe, elige otro directorio padre; la configuración se detiene sin modificar ese proyecto.
(
set -eu
mkdir scala-video-concat
cd scala-video-concat
curl -fsSLo scala-cli.gz https://github.com/VirtusLab/scala-cli/releases/download/v1.17.1/scala-cli-x86_64-pc-linux.gz
gzip -d scala-cli.gz
chmod +x scala-cli
./scala-cli version
)
Permanece en el directorio padre para ejecutar los comandos de shell restantes. Los paréntesis
limitan los cambios de directorio al bloque correspondiente. Guarda el código Scala siguiente como
scala-video-concat/Concat.scala; su
directiva using scala
fija la versión del compilador. La primera ejecución descarga el compilador y la biblioteca
estándar, por lo que necesita acceso a la red. No hay dependencias de bibliotecas de aplicación.
--jvm system usa el JDK que tienes instalado y
--server=false desactiva el servidor de compilación en segundo plano.
Instala FFmpeg
Instala una compilación de FFmpeg para tu distribución desde la página de descargas de FFmpeg. Confirma que ambas herramientas se ejecuten:
java -version && ffmpeg -version && ffprobe -version
Métodos de concatenación de videos
El demultiplexor concat lee una lista de archivos como una sola
entrada. Con -c copy, FFmpeg vuelve a multiplexar sus paquetes comprimidos
sin codificarlos de nuevo. No basta con que coincidan las extensiones de los archivos: los flujos
necesitan códecs, configuraciones y bases de tiempo compatibles.
Este ejemplo acepta clips MP4 locales completos con exactamente un flujo de video H.264 seguido de un flujo de audio AAC. Expórtalos con los mismos ajustes, incluidas las dimensiones, la tasa de fotogramas, el formato de píxel, la frecuencia de muestreo de audio y la disposición de canales. Usa clips cuyas duraciones de audio y video estén alineadas; el demultiplexor desplaza cada clip siguiente según la duración del archivo anterior, por lo que las pistas de distinta duración pueden dejar huecos. Los clips rotados y las pistas adicionales quedan fuera del alcance de este ejemplo.
La verificación previa compara determinadas propiedades de los flujos y hashes de configuración de los códecs obtenidos con ffprobe. Rechaza deliberadamente las discrepancias en lugar de intentar una conversión. Se trata de una comprobación de compatibilidad, no de un análisis completo de datos dañados: la copia de flujos no decodifica cada fotograma, y la coincidencia de las cabeceras no permite determinar que todos los paquetes sean válidos.
Guarda el programa Scala completo
//> using scala 3.3.7
import java.nio.charset.StandardCharsets.UTF_8
import java.nio.file.{Files, LinkOption, Path, Paths}
import java.util.Locale
import scala.sys.process.Process
import scala.util.control.NonFatal
object Concat:
def checkedPath(value: String): Path =
require(!value.exists(c => c == '\n' || c == '\r'), "Paths cannot contain line breaks")
Paths.get(value).toAbsolutePath
def signature(input: Path): String =
val fields = "codec_name,codec_type,profile,level,codec_tag_string,width,height," +
"pix_fmt,sample_aspect_ratio,field_order,r_frame_rate,time_base," +
"sample_fmt,sample_rate,channels,channel_layout,extradata_hash"
val info = Process(Seq(
"ffprobe", "-v", "error", "-show_data_hash", "sha256",
"-show_entries", s"stream=$fields:stream_side_data=rotation",
"-of", "default=noprint_wrappers=1", input.toString
)).!!
val lines = info.linesIterator.toVector
require(
lines.filter(_.startsWith("codec_name=")) == Vector("codec_name=h264", "codec_name=aac"),
s"Expected one H.264 video followed by one AAC audio stream: $input"
)
require(!lines.exists(_.startsWith("rotation=")), s"Rotated video is unsupported: $input")
require(lines.count(_.startsWith("extradata_hash=")) == 2, s"Missing codec configuration: $input")
info
def concatenate(args: Array[String]): Unit =
require(args.length >= 3, "Usage: Concat <output.mp4> <input1.mp4> <input2.mp4> [more.mp4 ...]")
val output = checkedPath(args(0))
require(output.toString.toLowerCase(Locale.ROOT).endsWith(".mp4"), "Output must end in .mp4")
require(!Files.exists(output, LinkOption.NOFOLLOW_LINKS), s"Output already exists: $output")
require(Files.isDirectory(output.getParent), s"Output directory does not exist: ${output.getParent}")
val inputs = args.drop(1).toVector.map { value =>
val path = checkedPath(value).toRealPath()
checkedPath(path.toString)
require(Files.isRegularFile(path) && Files.isReadable(path), s"Not a readable file: $path")
require(path.toString.toLowerCase(Locale.ROOT).endsWith(".mp4"), s"Input must end in .mp4: $path")
path
}
val first = signature(inputs.head)
for input <- inputs.tail do
require(signature(input) == first, s"Stream settings differ from the first clip: $input")
val list = Files.createTempFile("scala-concat-", ".ffconcat")
try
val entries = inputs.map { path =>
val escaped = path.toString.replace("'", "'\\''")
s"file '$escaped'"
}
Files.writeString(list, entries.mkString("\n") + "\n", UTF_8)
val status = Process(Seq(
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-nostdin", "-n",
"-f", "concat", "-safe", "0", "-i", list.toString,
"-map", "0:v:0", "-map", "0:a:0", "-c", "copy", output.toString
)).!
if status != 0 then throw IllegalStateException(s"FFmpeg failed (exit $status)")
finally
Files.deleteIfExists(list)
println(s"Created $output")
def main(args: Array[String]): Unit =
try concatenate(args)
catch
case NonFatal(error) =>
Console.err.println(s"Concatenation failed: ${error.getMessage}")
sys.exit(1)
Process(Seq(...)) pasa argumentos separados sin usar un shell. La lista temporal
tiene sus propias reglas de comillas de FFmpeg, por lo que el código también escapa los
apóstrofos en ella. Se admiten espacios, apóstrofos y guiones iniciales en los nombres de archivos
locales; los saltos de línea se rechazan. -safe 0 permite las rutas
absolutas en la lista generada por este programa. Usa tus propios clips de confianza y no los
modifiques durante la ejecución.
Ejecútalo con dos clips
Coloca tus clips en scala-video-concat o genera allí estas dos entradas pequeñas.
La primera es roja con un tono de 440 Hz; la segunda es azul con un tono de 880 Hz. Cada una dura
dos segundos. Estos comandos tampoco permiten reemplazar archivos existentes.
(
set -eu
cd scala-video-concat
for clip in 'part 1.mp4' 'part 2.mp4'; do
if [ -e "$clip" ] || [ -L "$clip" ]; then
printf 'Sample already exists: %s\n' "$clip" >&2
exit 1
fi
done
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=red:s=320x240:r=25:d=2' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000:duration=2' \
-c:v libx264 -pix_fmt yuv420p -c:a aac -ac 2 -shortest 'part 1.mp4'
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=blue:s=320x240:r=25:d=2' \
-f lavfi -i 'sine=frequency=880:sample_rate=48000:duration=2' \
-c:v libx264 -pix_fmt yuv420p -c:a aac -ac 2 -shortest 'part 2.mp4'
)
Ejecuta el programa guardado desde el directorio padre. Los argumentos posteriores a
-- pertenecen a Concat:
primero va la salida, seguida de las entradas en orden de reproducción. Añade más rutas de
entrada para unir más clips.
(
cd scala-video-concat &&
./scala-cli run Concat.scala --server=false --jvm system -- \
joined.mp4 'part 1.mp4' 'part 2.mp4'
)
Si la operación se completa correctamente, el programa imprime la ruta absoluta de
joined.mp4 y termina con un estado de salida de cero.
Para los clips de muestra, el resultado esperado es un video de aproximadamente cuatro segundos
con una resolución de 320 por 240 y audio estéreo: primero el rojo con el tono más grave y luego
el azul con el tono más agudo. La temporización de los paquetes AAC puede hacer que la duración
del contenedor sea ligeramente superior a cuatro segundos. FFmpeg puede ajustar las marcas de
tiempo de los paquetes AAC que se superponen en la unión e imprimir una advertencia. La copia de
flujos no garantiza un audio sin huecos y con precisión de muestra; usa un pipeline de
decodificación y codificación de audio cuando sea necesario. Inspecciona los flujos y decodifica
el resultado completo:
(
cd scala-video-concat &&
ffprobe -v error -show_entries stream=codec_name,width,height,sample_rate,channels:format=duration \
-of json joined.mp4 &&
ffmpeg -hide_banner -loglevel error -nostdin -xerror -i joined.mp4 \
-map 0:v:0 -map 0:a:0 -f null -
)
Gestión de errores y optimización
Las entradas ausentes, los medios ilegibles, las pistas no admitidas y las diferencias en los
ajustes de los flujos producen un error en stderr y un estado de salida de uno. El diagnóstico
de FFmpeg aparece antes del mensaje de error del programa si falla el análisis o la unión.
Consulta inmediatamente el estado del shell con echo $? cuando ejecutes
el programa de forma interactiva, o usa el estado del programa en la tarea que lo llama.
La comprobación del destino se ejecuta antes de validar las entradas. Una nueva ejecución cuando
joined.mp4 ya existe falla y conserva ese archivo, aunque falte una entrada.
La opción -n de FFmpeg añade una
protección contra sobrescritura al iniciarse;
-nostdin evita las solicitudes interactivas. Ejecuta un solo proceso de
escritura por destino. Este ejemplo no coordina procesos concurrentes.
La lista temporal se elimina cuando el programa termina normalmente, tanto si la operación tiene éxito como si falla. Si FFmpeg falla después de abrir el destino, puede quedar un MP4 parcial. Inspecciona y elimina tú mismo esa salida fallida, o elige un nombre nuevo antes de volver a intentarlo. Una terminación abrupta del proceso también puede dejar archivos temporales. Un estado de salida de cero indica que se completó la remultiplexación; usa la comprobación mediante decodificación y la reproducción para verificar la integridad de los medios y la unión.
Procesa clips que necesitan conversión
Si la comprobación de flujos rechaza tus clips, vuelve a exportarlos con ajustes uniformes antes de usar este programa. Para dimensiones distintas, ausencia de audio o códecs diferentes, es más apropiado un pipeline de decodificación y codificación separado. El filtro concat de FFmpeg requiere segmentos que comiencen en la marca de tiempo cero y tengan dimensiones compatibles; la ausencia de audio necesita una política explícita, como añadir silencio. Cambiar únicamente del demultiplexor a ese filtro no normaliza entradas arbitrarias.
Los fundidos cruzados también requieren volver a codificar y cambian la duración combinada porque los clips se superponen. El filtro xfade tiene requisitos de formato de video y base de tiempo, y el audio necesita su propia transición. El programa guardado realiza cortes directos y conserva ambas pistas; no implementa un modo de transición.
