Videos in Scala mit FFmpeg zusammenfügen
Um Clips mit denselben Exporteinstellungen zusammenzufügen, rufen Sie den Concat-Demuxer von FFmpeg in Scala auf und kopieren die Video- und Audiostreams in eine MP4-Datei. Das folgende Programm prüft die Eingaben, fügt sie in der Reihenfolge der Argumente zusammen und gibt bei Fehlern in der Validierung oder in FFmpeg einen Exit-Status ungleich null zurück. Es überschreibt kein bereits vorhandenes Ziel.
Umgebung einrichten
Diese Anleitung verwendet Linux x86-64, Bash, OpenJDK 21.0.12.1, Scala CLI 1.17.1 und Scala 3.3.7.
Die nativen Medienprüfungen verwenden FFmpeg und ffprobe 9.0.1. Stellen Sie sicher, dass
java, ffmpeg, ffprobe, curl und gzip
über Ihren PATH erreichbar sind; die Befehle für die Beispielclips benötigen
außerdem die FFmpeg-Encoder libx264 und aac.
Scala installieren
Verwenden Sie die Linux-Version von Scala CLI,
um den Launcher lokal für dieses Beispiel bereitzuhalten. Fügen Sie diesen Block in Bash ein,
ausgehend von einem Verzeichnis, in dem Sie ein neues Projekt erstellen können. Falls
scala-video-concat bereits existiert, wählen Sie ein anderes übergeordnetes Verzeichnis;
die Einrichtung stoppt, ohne das vorhandene Projekt zu verändern.
(
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
)
Bleiben Sie für die weiteren Shell-Befehle im übergeordneten Verzeichnis. Die Klammern begrenzen
Verzeichniswechsel auf den jeweiligen Block. Speichern Sie den folgenden Scala-Code als
scala-video-concat/Concat.scala; die darin enthaltene
Direktive using scala
legt die Compilerversion fest. Beim ersten Start werden Compiler und Standardbibliothek
heruntergeladen, daher ist Netzwerkzugriff erforderlich. Es gibt keine Abhängigkeiten von
Anwendungsbibliotheken. --jvm system verwendet Ihr installiertes JDK, und
--server=false deaktiviert den Kompilierungsserver im Hintergrund.
FFmpeg installieren
Installieren Sie einen FFmpeg-Build für Ihre Distribution über die FFmpeg-Downloadseite. Prüfen Sie, ob beide Tools funktionieren:
java -version && ffmpeg -version && ffprobe -version
Methoden zum Zusammenfügen von Videos
Der Concat-Demuxer liest eine Dateiliste als eine einzige Eingabe.
Mit -c copy verpackt FFmpeg die komprimierten Pakete per Remuxing neu, ohne sie
erneut zu codieren. Übereinstimmende Dateiendungen allein reichen nicht aus: Die Streams benötigen
kompatible Codecs, Konfigurationen und Zeitbasen.
Dieses Beispiel akzeptiert vollständige lokale MP4-Clips mit genau einem H.264-Videostream, gefolgt von einem AAC-Audiostream. Exportieren Sie sie mit denselben Einstellungen, einschließlich Abmessungen, Bildrate, Pixelformat, Audio-Abtastrate und Kanalanordnung. Verwenden Sie Clips mit aufeinander abgestimmter Audio- und Videodauer; der Demuxer versetzt jeden folgenden Clip anhand der Dauer der vorherigen Datei, sodass ungleiche Spurlängen Lücken verursachen können. Gedrehte Clips und zusätzliche Spuren werden in diesem Beispiel nicht berücksichtigt.
Die Vorprüfung vergleicht ausgewählte Stream-Eigenschaften und Hashes der Codec-Konfiguration aus ffprobe. Sie weist Abweichungen bewusst zurück, statt eine Konvertierung zu versuchen. Dies ist eine Kompatibilitätsprüfung, keine vollständige Prüfung auf beschädigte Daten: Beim Kopieren der Streams wird nicht jedes Einzelbild decodiert, und übereinstimmende Header belegen nicht, dass jedes Paket gültig ist.
Das vollständige Scala-Programm speichern
//> 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(...)) übergibt separate Argumente ohne Shell. Für die temporäre Liste gelten
eigene FFmpeg-Regeln für Anführungszeichen, daher maskiert der Code dort auch Apostrophe.
Leerzeichen, Apostrophe und führende Bindestriche in lokalen Dateinamen funktionieren;
Zeilenumbrüche werden abgelehnt. -safe 0 erlaubt die absoluten Pfade in der
Liste, die dieses Programm erzeugt. Verwenden Sie eigene vertrauenswürdige Clips und verändern
Sie sie während eines Durchlaufs nicht.
Mit zwei Clips ausführen
Legen Sie Ihre Clips in scala-video-concat ab oder erzeugen Sie dort diese beiden kleinen
Eingabedateien. Die erste ist rot mit einem 440 Hz-Ton, die zweite blau mit einem 880 Hz-Ton.
Jede dauert zwei Sekunden. Auch diese Befehle ersetzen keine vorhandenen Dateien.
(
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'
)
Führen Sie das gespeicherte Programm aus dem übergeordneten Verzeichnis aus. Argumente nach
-- gehören zu Concat:
Zuerst kommt die Ausgabe, dann folgen die Eingaben in Wiedergabereihenfolge. Fügen Sie weitere
Eingabepfade hinzu, um mehr Clips zusammenzufügen.
(
cd scala-video-concat &&
./scala-cli run Concat.scala --server=false --jvm system -- \
joined.mp4 'part 1.mp4' 'part 2.mp4'
)
Bei Erfolg gibt das Programm den absoluten Pfad von joined.mp4 aus und beendet
sich mit Status null. Bei den Beispielclips ist ein ungefähr vier Sekunden langes Video mit einer
Auflösung von 320 mal 240 und Stereo-Audio zu erwarten: zuerst Rot und der tiefere Ton, danach Blau
und der höhere Ton. Durch das Timing der AAC-Pakete kann die Containerdauer etwas länger als vier
Sekunden sein. FFmpeg kann überlappende Zeitstempel von AAC-Paketen an der Schnittstelle anpassen
und eine Warnung ausgeben. Das Kopieren von Streams garantiert kein samplegenaues, lückenloses
Audio; verwenden Sie eine Pipeline zum Decodieren und Codieren von Audio, wenn dies erforderlich
ist. Prüfen Sie die Streams und decodieren Sie das gesamte Ergebnis:
(
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 -
)
Fehlerbehandlung und Optimierung
Fehlende Eingaben, unlesbare Medien, nicht unterstützte Spuren und abweichende Stream-Einstellungen
führen zu einer Fehlermeldung auf stderr und zum Exit-Status eins. Schlägt die Analyse oder das
Zusammenfügen fehl, erscheint die FFmpeg-Diagnose vor der Fehlermeldung des Programms. Prüfen Sie
bei interaktiver Ausführung den Shell-Status sofort mit echo $?, oder werten
Sie den Programmstatus im aufrufenden Job aus.
Die Prüfung des Ziels erfolgt vor der Eingabevalidierung. Ein erneuter Durchlauf schlägt fehl,
wenn joined.mp4 bereits vorhanden ist, und erhält diese Datei, selbst wenn eine
Eingabe fehlt. Die FFmpeg-Option -n
verhindert zusätzlich beim Start das Überschreiben;
-nostdin verhindert interaktive Eingabeaufforderungen. Lassen Sie pro Ziel nur
einen Prozess schreiben. Dieses Beispiel koordiniert keine gleichzeitig laufenden Prozesse.
Bei regulärem Erfolg oder Fehlschlag wird die temporäre Liste entfernt. Schlägt FFmpeg nach dem Öffnen des Ziels fehl, kann eine unvollständige MP4-Datei zurückbleiben. Prüfen und entfernen Sie diese fehlerhafte Ausgabe selbst oder wählen Sie vor einem erneuten Versuch einen neuen Namen. Auch ein abrupter Prozessabbruch kann temporäre Dateien hinterlassen. Ein Exit-Status von null meldet, dass das Remuxing abgeschlossen wurde; prüfen Sie die Medienintegrität und die Schnittstelle durch Decodieren und Wiedergabe.
Clips verarbeiten, die eine Konvertierung benötigen
Wenn die Stream-Prüfung Ihre Clips ablehnt, exportieren Sie sie vor der Verwendung dieses Programms erneut mit einheitlichen Einstellungen. Für unterschiedliche Abmessungen, fehlendes Audio oder verschiedene Codecs eignet sich eine separate Pipeline zum Decodieren und Codieren besser. Der Concat-Filter von FFmpeg setzt Segmente voraus, die bei Zeitstempel null beginnen und kompatible Abmessungen haben; für fehlendes Audio ist eine explizite Vorgehensweise nötig, etwa das Hinzufügen von Stille. Der Wechsel vom Demuxer zu diesem Filter allein normalisiert keine beliebigen Eingaben.
Überblendungen erfordern ebenfalls erneutes Encoding und verändern die Gesamtdauer, weil sich Clips überlappen. Der xfade-Filter stellt Anforderungen an Videoformat und Zeitbasis, und Audio benötigt einen eigenen Übergang. Das gespeicherte Programm führt harte Schnitte aus und behält beide Spuren bei; es implementiert keinen Übergangsmodus.
