Concatenar vídeos em Scala com FFmpeg
Para juntar clipes exportados com as mesmas configurações, chame o demuxer concat do FFmpeg a partir do Scala e copie os fluxos de vídeo e áudio para um único MP4. O programa abaixo verifica as entradas, junta os arquivos na ordem dos argumentos e retorna um status de saída diferente de zero quando a validação ou o FFmpeg falha. Ele se recusa a sobrescrever um destino já existente.
Configure o ambiente
Este passo a passo usa Linux x86-64, Bash, OpenJDK 21.0.12.1, Scala CLI 1.17.1 e Scala 3.3.7. As
verificações nativas de mídia usam FFmpeg e ffprobe 9.0.1. Tenha java, ffmpeg, ffprobe, curl e gzip
no seu PATH; os comandos dos clipes de exemplo também precisam dos codificadores libx264 e aac do FFmpeg.
Instale o Scala
Use a versão do Scala CLI para Linux
para manter o inicializador local a este exemplo. Cole este bloco no Bash a partir de um diretório
onde você possa criar um novo projeto. Se scala-video-concat já existir, escolha outro diretório
pai; a configuração é interrompida sem alterar esse projeto.
(
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
)
Permaneça no diretório pai para os demais comandos de shell. Os parênteses mantêm locais as
mudanças de diretório de cada bloco. Salve o código Scala abaixo como scala-video-concat/Concat.scala; a
diretiva using scala
fixa a versão do compilador. A primeira execução baixa o compilador e a biblioteca padrão, por isso
precisa de acesso à rede. Não há dependências de bibliotecas de aplicação. --jvm system usa o JDK
instalado na sua máquina, e --server=false desativa o servidor de compilação em segundo plano.
Instale o FFmpeg
Instale uma build do FFmpeg para a sua distribuição usando a página de download do FFmpeg. Confirme que as duas ferramentas funcionam:
java -version && ffmpeg -version && ffprobe -version
Métodos de concatenação de vídeo
O demuxer concat lê uma lista de arquivos como uma única
entrada. Com -c copy, o FFmpeg remuxa os pacotes compactados sem codificá-los novamente.
Extensões de nome de arquivo iguais não bastam: os fluxos precisam ter codecs, configuração e bases
de tempo compatíveis.
Este exemplo aceita clipes MP4 locais completos com exatamente um fluxo de vídeo H.264 seguido de um fluxo de áudio AAC. Exporte-os com as mesmas configurações, incluindo dimensões, taxa de quadros, formato de pixel, taxa de amostragem de áudio e layout de canais. Use clipes com durações de áudio e vídeo alinhadas; o demuxer desloca cada clipe seguinte com base na duração do arquivo anterior, então faixas de comprimentos diferentes podem deixar lacunas. Clipes rotacionados e faixas adicionais estão fora do escopo deste exemplo.
A verificação prévia compara propriedades selecionadas dos fluxos e hashes de configuração dos codecs obtidos com o ffprobe. Ela rejeita divergências de propósito, em vez de tentar uma conversão. Trata-se de uma verificação de compatibilidade, não de uma varredura completa em busca de corrupção: a cópia de fluxos não decodifica todos os quadros, e cabeçalhos compatíveis não conseguem comprovar que todos os pacotes são válidos.
Salve o 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(...)) passa argumentos separados sem usar um shell. A lista temporária tem suas próprias
regras de aspas do FFmpeg, por isso o código também faz o escape dos apóstrofos nela. Espaços,
apóstrofos e hifens iniciais em nomes de arquivos locais funcionam; quebras de linha são rejeitadas.
-safe 0 permite os caminhos absolutos na lista gerada por este programa. Use seus próprios
clipes confiáveis e não os altere durante uma execução.
Execute com dois clipes
Coloque seus clipes em scala-video-concat ou gere lá estas duas pequenas entradas. A primeira é
vermelha, com um tom de 440 Hz; a segunda é azul, com um tom de 880 Hz. Cada uma dura dois segundos.
Estes comandos também se recusam a substituir arquivos 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'
)
Execute o programa salvo a partir do diretório pai. Os argumentos após -- pertencem a
Concat: a saída vem primeiro, seguida das entradas na ordem de reprodução. Adicione mais
caminhos de entrada para juntar mais clipes.
(
cd scala-video-concat &&
./scala-cli run Concat.scala --server=false --jvm system -- \
joined.mp4 'part 1.mp4' 'part 2.mp4'
)
Em caso de sucesso, o programa imprime o caminho absoluto de joined.mp4 e termina com status
zero. Para os clipes de exemplo, espere cerca de quatro segundos de vídeo em 320 por 240 com áudio
estéreo: primeiro o vermelho com o tom mais grave, depois o azul com o tom mais agudo. O timing dos
pacotes AAC pode deixar a duração do contêiner um pouco maior que quatro segundos. O FFmpeg pode
ajustar timestamps de pacotes AAC sobrepostos no ponto de junção e imprimir um aviso. A cópia de
fluxos não garante áudio sem lacunas e com precisão de amostra; use um pipeline de decodificação e
codificação de áudio quando isso for necessário. Inspecione os fluxos e decodifique o resultado
inteiro:
(
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 -
)
Tratamento de erros e otimização
Entradas ausentes, mídia ilegível, faixas não suportadas e configurações de fluxo divergentes geram
um erro no stderr e status de saída um. O diagnóstico do FFmpeg aparece antes da mensagem de falha
do programa se a análise ou a junção falhar. Verifique o status do shell imediatamente com echo $?
ao executar de forma interativa, ou use o status do programa na tarefa que o invoca.
A verificação do destino é executada antes da validação das entradas. Uma nova execução com
joined.mp4 já presente falha e preserva esse arquivo, mesmo que alguma entrada esteja ausente. A
opção -n do FFmpeg acrescenta uma recusa de sobrescrita quando ele é
iniciado; -nostdin impede prompts interativos. Execute um único processo de escrita por destino.
Este exemplo não coordena processos concorrentes.
A lista temporária é removida em caso de sucesso ou falha comum. Se o FFmpeg falhar depois de abrir o destino, um MP4 parcial pode permanecer. Inspecione e remova você mesmo essa saída com falha, ou escolha um novo nome antes de tentar novamente. Um encerramento abrupto do processo também pode deixar arquivos temporários. Um status de saída zero informa que o remux foi concluído; use a verificação de decodificação e a reprodução para conferir a integridade da mídia e a junção.
Trate clipes que precisam de conversão
Se a verificação de fluxos rejeitar seus clipes, exporte-os novamente com configurações consistentes antes de usar este programa. Para dimensões mistas, áudio ausente ou codecs diferentes, um pipeline separado de decodificação e codificação é mais adequado. O filtro concat do FFmpeg exige segmentos que comecem no timestamp zero e dimensões compatíveis; o áudio ausente precisa de uma política explícita, como adicionar silêncio. Trocar o demuxer por esse filtro, por si só, não normaliza entradas arbitrárias.
Crossfades também exigem recodificação e alteram a duração combinada, porque os clipes se sobrepõem. O filtro xfade tem requisitos de formato de vídeo e de base de tempo, e o áudio precisa de sua própria transição. O programa salvo faz cortes secos e mantém as duas faixas; ele não implementa um modo de transição.
