Concatenar videos en Scala con FFmpeg
La concatenación de videos es un requisito común en las aplicaciones de procesamiento de medios, ya sea que estés creando un editor de video, armando compilaciones o automatizando flujos de trabajo de video. En esta guía exploraremos cómo concatenar videos de forma eficiente con Scala y FFmpeg, y cubriremos distintos métodos y buenas prácticas.
Configura tu entorno
Instala Scala
La forma recomendada de instalar Scala es con Coursier (cs setup):
macOS:
brew install coursier/formulas/coursier && cs setup
Linux:
curl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz | gzip -d > cs && chmod +x cs && ./cs setup
Windows: Descarga y ejecuta el instalador de Scala para Windows
Instala FFmpeg
macOS:
brew install ffmpeg
Linux (Ubuntu/Debian):
sudo apt update && sudo apt install ffmpeg
Windows: Descarga desde el sitio web oficial de FFmpeg
Métodos de concatenación de video
FFmpeg ofrece dos métodos principales para concatenar videos:
- Concat demuxer: ideal para videos con el mismo códec y formato
- Concat filter: más flexible, pero requiere volver a codificar
Implementemos ambos métodos en Scala:
import scala.sys.process.*
import scala.util.{Try, Success, Failure}
import java.io.{File, PrintWriter}
import java.nio.file.{Files, Paths}
enum ConcatMethod:
case Demuxer, Filter
case class VideoFile(path: String):
def exists: Boolean = Files.exists(Paths.get(path))
def extension: String = path.split("\\.").lastOption.getOrElse("")
object VideoConcatenator:
def createConcatFile(videos: Seq[VideoFile], tempFile: String): Try[Unit] = Try {
val writer = PrintWriter(File(tempFile))
try
videos.foreach { video =>
val path = Paths.get(video.path).toAbsolutePath.toString
require(!path.contains('\n') && !path.contains('\r'), "Paths must not contain newlines")
val escaped = path.replace("'", "'\\''")
writer.println(s"file '$escaped'")
}
finally
writer.close()
}
def concatenateVideos(
videos: Seq[VideoFile],
output: String,
method: ConcatMethod = ConcatMethod.Demuxer
): Try[Boolean] = Try {
if videos.isEmpty then throw IllegalArgumentException("No videos provided")
if !videos.forall(_.exists) then throw IllegalArgumentException("One or more input videos not found")
method match
case ConcatMethod.Demuxer =>
val tempFile = Files.createTempFile("video-concat-", ".txt").toString
createConcatFile(videos, tempFile).get
val cmd = Seq(
"ffmpeg",
"-f", "concat",
"-safe", "0",
"-i", tempFile,
"-c", "copy",
"-y",
output
)
try cmd.! == 0
finally Files.deleteIfExists(Paths.get(tempFile))
case ConcatMethod.Filter =>
val inputs = videos.flatMap(v => Seq("-i", v.path))
val inputsForFilter = videos.indices.map(i => s"[$i:v:0][$i:a:0]").mkString
val filter = s"${inputsForFilter}concat=n=${videos.length}:v=1:a=1[outv][outa]"
val cmd = Seq("ffmpeg") ++ inputs ++ Seq(
"-filter_complex", filter,
"-map", "[outv]",
"-map", "[outa]",
"-y",
output
)
cmd.! == 0
}
@main def run(args: String*): Unit =
if args.length < 3 then
println("Usage: VideoConcatenator <output_file> <input_file1> <input_file2> [input_file3...] [--filter]")
sys.exit(1)
val useFilter = args.contains("--filter")
val output = args(0)
val videos = args.slice(1, args.length).filterNot(_ == "--filter").map(VideoFile.apply)
val method = if useFilter then ConcatMethod.Filter else ConcatMethod.Demuxer
concatenateVideos(videos, output, method) match
case Success(true) => println(s"Successfully concatenated videos to $output")
case Success(false) => println("Failed to concatenate videos: FFmpeg returned non-zero exit code")
case Failure(e) => println(s"Error concatenating videos: ${e.getMessage}")
Entender los métodos
Concat demuxer
El concat demuxer es más rápido porque no requiere volver a codificar. Funciona de la siguiente manera:
- Crear un archivo de texto que liste los videos de entrada
- Usar el concat demuxer de FFmpeg para combinarlos
- Copiar los streams directamente a la salida
Este método es ideal cuando tus videos comparten el mismo códec y formato.
Concat filter
El concat filter es más flexible, pero más lento porque requiere volver a codificar. Usa este método cuando:
- Los videos tienen códecs distintos
- Necesitas añadir transiciones
- Trabajas con códecs distintos después de normalizar las dimensiones del video
Este ejemplo de filtro requiere un stream de video y uno de audio por cada entrada, dimensiones de video coincidentes y marcas de tiempo que empiecen en cero. Normaliza primero esas propiedades. No añade transiciones.
Manejo de errores y optimización
Nuestra implementación incluye varias funciones de manejo de errores:
- Validación de la entrada
- Comprobaciones de existencia de archivos
- Liberación adecuada de recursos
- Reporte detallado de errores
Para obtener un mejor rendimiento:
- Usa el método demuxer cuando sea posible
- Asegúrate de que los videos de entrada estén en el mismo formato
- Considera usar aceleración por hardware con
-hwaccel - Limpia los archivos temporales
Técnicas avanzadas
Añadir transiciones
El siguiente ejemplo, solo de video, aplica un crossfade a exactamente dos clips con dimensiones y
velocidad de fotogramas coincidentes. Indica la duración del primer clip en segundos; el audio se
omite deliberadamente con -an.
def concatenateWithTransitions(
first: VideoFile,
second: VideoFile,
firstDuration: Double,
output: String,
transitionDuration: Double = 1.0
): Try[Boolean] = Try {
require(transitionDuration > 0 && firstDuration > transitionDuration)
val offset = firstDuration - transitionDuration
val filter = "[0:v]settb=AVTB,setpts=PTS-STARTPTS[v0];" +
"[1:v]settb=AVTB,setpts=PTS-STARTPTS[v1];" +
s"[v0][v1]xfade=transition=fade:duration=$transitionDuration:offset=$offset[outv]"
val cmd = Seq("ffmpeg", "-i", first.path, "-i", second.path,
"-filter_complex", filter,
"-map", "[outv]", "-an",
"-y",
output
)
cmd.! == 0
}
Manejar formatos diferentes
Este ejemplo normaliza los códecs, un lienzo de 1280 por 720, la velocidad de fotogramas, las marcas de tiempo y el formato de audio. Cada entrada debe contener un stream de audio; añade silencio por separado para los clips que no tengan audio.
def normalizeVideo(input: VideoFile, output: String): Try[Boolean] = Try {
val cmd = Seq(
"ffmpeg",
"-i", input.path,
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,setpts=PTS-STARTPTS",
"-af", "asetpts=PTS-STARTPTS", "-ar", "48000", "-ac", "2",
"-c:v", "libx264",
"-c:a", "aac",
"-y",
output
)
cmd.! == 0
}
Conclusión
La concatenación de videos en Scala con FFmpeg ofrece una forma potente de combinar archivos de video mediante programación. La elección entre los métodos concat demuxer y concat filter depende de tus requisitos específicos de velocidad frente a flexibilidad.
Para obtener capacidades de procesamiento de video más avanzadas, incluida la concatenación automatizada de videos a escala, echa un vistazo al servicio de Encoding de video de Transloadit.
