Concaténer des vidéos en Scala avec FFmpeg
Pour assembler des clips exportés avec les mêmes paramètres, appelez le démultiplexeur concat de FFmpeg depuis Scala et copiez les flux vidéo et audio dans un seul MP4. Le programme ci-dessous vérifie les entrées, les assemble dans l’ordre des arguments et renvoie un code de sortie non nul lorsque la validation ou FFmpeg échoue. Il refuse d’écraser une destination existante.
Préparer votre environnement
Ce tutoriel utilise Linux x86-64, Bash, OpenJDK 21.0.12.1, Scala CLI 1.17.1 et Scala 3.3.7. Les
vérifications média natives utilisent FFmpeg et ffprobe 9.0.1. Assurez-vous que java, ffmpeg, ffprobe, curl et gzip
se trouvent dans votre PATH ; les commandes de génération des clips d’exemple nécessitent aussi les encodeurs libx264 et aac de FFmpeg.
Installer Scala
Utilisez la version Linux de Scala CLI
pour garder le lanceur local à cet exemple. Collez ce bloc dans Bash depuis un répertoire où vous
pouvez créer un nouveau projet. Si scala-video-concat existe déjà, choisissez un autre répertoire
parent ; l’installation s’arrête sans modifier ce projet.
(
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
)
Restez dans le répertoire parent pour les autres commandes shell. Les parenthèses gardent locaux
les changements de répertoire de chaque bloc. Enregistrez le code Scala ci-dessous sous scala-video-concat/Concat.scala ; sa
directive using scala
fixe la version du compilateur. La première exécution télécharge le compilateur et la bibliothèque
standard, elle nécessite donc un accès réseau. Il n’y a aucune dépendance à des bibliothèques
applicatives. --jvm system utilise le JDK que vous avez installé,
et --server=false désactive le serveur de compilation en arrière-plan.
Installer FFmpeg
Installez une version de FFmpeg adaptée à votre distribution à l’aide de la page de téléchargement de FFmpeg. Vérifiez que les deux outils fonctionnent :
java -version && ffmpeg -version && ffprobe -version
Méthodes de concaténation vidéo
Le démultiplexeur concat lit une liste de fichiers comme une seule
entrée. Avec -c copy, FFmpeg remultiplexe leurs paquets compressés sans les réencoder.
Des extensions de fichier identiques ne suffisent pas : les flux doivent avoir des codecs, une
configuration et des bases de temps compatibles.
Cet exemple accepte des clips MP4 locaux complets comportant exactement un flux vidéo H.264 suivi d’un flux audio AAC. Exportez-les avec les mêmes paramètres, y compris les dimensions, la fréquence d’images, le format de pixel, la fréquence d’échantillonnage audio et la disposition des canaux. Utilisez des clips dont les durées audio et vidéo sont alignées ; le démultiplexeur décale chaque clip suivant selon la durée du fichier précédent, si bien que des pistes de longueurs différentes peuvent laisser des trous. Les clips pivotés et les pistes supplémentaires sortent du cadre de cet exemple.
La vérification préalable compare certaines propriétés des flux et des empreintes de configuration des codecs fournies par ffprobe. Elle rejette délibérément les incompatibilités au lieu de tenter une conversion. Il s’agit d’une vérification de compatibilité, pas d’une analyse complète de corruption : la copie de flux ne décode pas chaque image, et des en-têtes identiques ne peuvent pas établir que chaque paquet est valide.
Enregistrer le programme Scala complet
//> 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(...)) transmet des arguments séparés sans passer par un shell. La liste temporaire a ses
propres règles de citation FFmpeg, le code y échappe donc aussi les apostrophes. Les espaces, les
apostrophes et les tirets initiaux dans les noms de fichiers locaux fonctionnent ; les sauts de
ligne sont rejetés. -safe 0 autorise les chemins absolus dans la liste générée par ce
programme. Utilisez vos propres clips de confiance et ne les modifiez pas pendant une exécution.
L’exécuter avec deux clips
Placez vos clips dans scala-video-concat, ou générez-y ces deux petites entrées. La première est rouge
avec une tonalité de 440 Hz ; la seconde est bleue avec une tonalité de 880 Hz. Chacune dure
deux secondes. Ces commandes refusent également de remplacer des fichiers existants.
(
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'
)
Exécutez le programme enregistré depuis le répertoire parent. Les arguments après -- sont destinés à Concat :
la sortie vient en premier, suivie des entrées dans l’ordre de lecture. Ajoutez d’autres chemins
d’entrée pour assembler davantage de clips.
(
cd scala-video-concat &&
./scala-cli run Concat.scala --server=false --jvm system -- \
joined.mp4 'part 1.mp4' 'part 2.mp4'
)
En cas de succès, le programme affiche le chemin absolu de joined.mp4 et se termine avec le code de
sortie zéro. Pour les clips d’exemple, attendez-vous à environ quatre secondes de vidéo en 320 par
240 avec un son stéréo : le rouge et la tonalité la plus grave d’abord, puis le bleu et la tonalité
la plus aiguë. La synchronisation des paquets AAC peut rendre la durée du conteneur légèrement
supérieure à quatre secondes. FFmpeg peut ajuster les horodatages des paquets AAC qui se
chevauchent au niveau de la jonction et afficher un avertissement. La copie de flux ne garantit pas
un audio précis à l’échantillon près et sans trous ; utilisez un pipeline de décodage et
d’encodage audio lorsque c’est nécessaire. Inspectez les flux et décodez l’intégralité du
résultat :
(
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 -
)
Gestion des erreurs et optimisation
Les entrées manquantes, les médias illisibles, les pistes non prises en charge et les paramètres
de flux différents produisent une erreur sur stderr et le code de sortie un. Le diagnostic de
FFmpeg apparaît avant le message d’échec du programme si l’analyse ou l’assemblage échoue.
Vérifiez le code de retour du shell immédiatement avec echo $? lors d’une exécution
interactive, ou utilisez le code de sortie du programme dans votre tâche appelante.
La vérification de la destination s’exécute avant la validation des entrées. Une nouvelle exécution
alors que joined.mp4 existe déjà échoue et préserve ce fichier, même si une entrée est manquante.
L’option -n de FFmpeg ajoute un refus d’écrasement au démarrage de FFmpeg ;
-nostdin empêche les invites interactives. Exécutez un seul processus d’écriture par
destination. Cet exemple ne coordonne pas les processus concurrents.
La liste temporaire est supprimée en cas de succès ou d’échec ordinaire. Si FFmpeg échoue après avoir ouvert la destination, un MP4 partiel peut subsister. Inspectez et supprimez vous-même cette sortie en échec, ou choisissez un nouveau nom avant de réessayer. Un arrêt brutal du processus peut aussi laisser des fichiers temporaires. Un code de sortie égal à zéro indique que le remultiplexage s’est terminé ; utilisez la vérification par décodage et la lecture pour contrôler l’intégrité du média et la jonction.
Gérer les clips qui nécessitent une conversion
Si la vérification des flux rejette vos clips, réexportez-les avec des paramètres cohérents avant d’utiliser ce programme. Pour des dimensions hétérogènes, un audio manquant ou des codecs différents, un pipeline distinct de décodage et d’encodage est plus approprié. Le filtre concat de FFmpeg exige des segments commençant à l’horodatage zéro et des dimensions compatibles ; l’absence d’audio nécessite une stratégie explicite, par exemple l’ajout de silence. Passer du démultiplexeur à ce filtre ne suffit pas, à lui seul, à normaliser des entrées arbitraires.
Les fondus enchaînés nécessitent aussi un réencodage et modifient la durée totale, car les clips se chevauchent. Le filtre xfade impose des exigences de format vidéo et de base de temps, et l’audio a besoin de sa propre transition. Le programme enregistré effectue des coupes franches et conserve les deux pistes ; il n’implémente pas de mode de transition.
