Traitement vidéo Java : transcoder, redimensionner et filigraner
JavaCV vous permet de décoder une vidéo avec FFmpeg, d’en modifier les images avec OpenCV et d’encoder un MP4 depuis Java. Ce tutoriel construit un seul programme en ligne de commande avec trois opérations : transcoder en H.264, redimensionner à la résolution choisie et incruster un filigrane textuel. Chaque opération conserve la première piste audio en AAC lorsqu’elle existe.
Prérequis
Pour l’environnement testé ici, utilisez une machine Linux x86_64 avec Bash, JDK 21.0.12.1 et
Maven 3.9.16. Installez aussi les outils en ligne de commande ffmpeg et ffprobe ; les commandes de vérification ont été testées
avec FFmpeg 9.0.1. Elles s’exécutent indépendamment des bibliothèques FFmpeg 7.1.1 intégrées à JavaCV.
L’exemple cible des vidéos SDR locales et lisibles, à fréquence d’images constante, avec des pixels carrés, sans rotation, et dont l’audio et la vidéo commencent ensemble à zéro. Utilisez une seule piste vidéo et, éventuellement, une piste audio mono ou stéréo. L’exemple ne conserve pas les sous-titres, les pistes supplémentaires, les métadonnées, le HDR ni la synchronisation à fréquence d’images variable. Travaillez dans un répertoire que vous contrôlez et exécutez les traitements l’un après l’autre.
Créez un nouveau répertoire de projet dans votre éditeur. À l’intérieur, créez pom.xml et
src/main/java/VideoProcessor.java. Ouvrez un terminal dans ce répertoire de projet ; toutes les commandes ci-dessous
s’y exécutent. Enregistrez ce pom.xml complet :
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>java-video</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<native.platform>linux-x86_64</native.platform>
</properties>
<dependencies>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv</artifactId>
<version>1.5.12</version>
<exclusions>
<exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>7.1.1-1.5.12</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>7.1.1-1.5.12</version>
<classifier>${native.platform}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>opencv</artifactId>
<version>4.11.0-1.5.12</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>opencv</artifactId>
<version>4.11.0-1.5.12</version>
<classifier>${native.platform}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>openblas</artifactId>
<version>0.3.30-1.5.12</version>
<classifier>${native.platform}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>1.5.12</version>
<classifier>${native.platform}</classifier>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.4.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</build>
</project>
Épingler ces versions permet de garder les wrappers Java et les bibliothèques natives cohérents. Au lieu de télécharger chaque backend JavaCV pour chaque plateforme, ce projet inclut FFmpeg, OpenCV et leurs dépendances pour Linux x86_64. Bytedeco explique pourquoi il faut à la fois les artefacts Java et les artefacts natifs propres à chaque plateforme.
Transcodage de vidéos en Java
Enregistrez la classe suivante sous src/main/java/VideoProcessor.java. Les trois opérations partagent le même
décodeur, le même traitement audio et la même configuration d’encodeur. Seule l’étape de traitement
d’image change.
L’encodeur demande 5 Mbps pour la vidéo ; il s’agit d’un budget de débit, et non d’une taille ou d’un niveau de qualité garantis en sortie. Les binaires natifs standard utilisés ici encodent le H.264 avec OpenH264 : l’exemple ne définit donc pas d’options CRF propres à x264. L’audio est décodé puis réencodé, plutôt que copié sans perte. Utilisez la version épinglée 1.5.12 : son enregistreur vide les paquets audio différés à la fin du fichier ; la version 1.5.11 peut perdre le dernier paquet AAC.
import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AAC;
import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264;
import static org.bytedeco.ffmpeg.global.avutil.AV_PIX_FMT_YUV420P;
import static org.bytedeco.opencv.global.opencv_imgproc.FONT_HERSHEY_SIMPLEX;
import static org.bytedeco.opencv.global.opencv_imgproc.INTER_AREA;
import static org.bytedeco.opencv.global.opencv_imgproc.INTER_LINEAR;
import static org.bytedeco.opencv.global.opencv_imgproc.LINE_AA;
import static org.bytedeco.opencv.global.opencv_imgproc.getTextSize;
import static org.bytedeco.opencv.global.opencv_imgproc.putText;
import static org.bytedeco.opencv.global.opencv_imgproc.resize;
import java.nio.file.Files;
import java.nio.file.Path;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.OpenCVFrameConverter;
import org.bytedeco.opencv.opencv_core.Mat;
import org.bytedeco.opencv.opencv_core.Point;
import org.bytedeco.opencv.opencv_core.Scalar;
import org.bytedeco.opencv.opencv_core.Size;
public class VideoProcessor {
public static void main(String[] args) throws Exception {
if (args.length < 3) throw new IllegalArgumentException(
"Usage: VideoProcessor transcode INPUT OUTPUT\n"
+ " VideoProcessor resize INPUT OUTPUT WIDTH HEIGHT\n"
+ " VideoProcessor watermark INPUT OUTPUT TEXT");
String operation = args[0];
int expected = switch (operation) {
case "transcode" -> 3;
case "resize" -> 5;
case "watermark" -> 4;
default -> throw new IllegalArgumentException("Unknown operation: " + operation);
};
if (args.length != expected) throw new IllegalArgumentException("Wrong argument count");
int width = operation.equals("resize") ? Integer.parseInt(args[3]) : 0;
int height = operation.equals("resize") ? Integer.parseInt(args[4]) : 0;
if (operation.equals("resize")) checkDimensions(width, height);
String text = operation.equals("watermark") ? args[3] : "";
if (operation.equals("watermark") && (text.isBlank() || !text.matches("[ -~]+"))) {
throw new IllegalArgumentException("Use nonblank printable ASCII watermark text");
}
process(Path.of(args[1]).toAbsolutePath(), Path.of(args[2]).toAbsolutePath(),
operation, width, height, text);
}
private static void checkDimensions(int width, int height) {
if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0) {
throw new IllegalArgumentException("Output dimensions must be positive and even");
}
}
private static void process(Path input, Path output, String operation,
int width, int height, String text) throws Exception {
if (!Files.isRegularFile(input)) throw new IllegalArgumentException("Video file not found");
// Reserve a new destination; never claim ownership of an existing file.
Files.createFile(output);
boolean completed = false;
try {
try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(input.toString());
OpenCVFrameConverter.ToMat converter = new OpenCVFrameConverter.ToMat();
Mat resized = new Mat();
Point origin = new Point(24, 48);
Scalar white = new Scalar(255, 255, 255, 0);
Scalar black = new Scalar(0, 0, 0, 0)) {
grabber.start();
if (grabber.getImageWidth() <= 0 || grabber.getImageHeight() <= 0) {
throw new IllegalArgumentException("Input must contain video");
}
if (!operation.equals("resize")) {
width = grabber.getImageWidth();
height = grabber.getImageHeight();
}
checkDimensions(width, height);
double fps = grabber.getFrameRate();
if (!Double.isFinite(fps) || fps <= 0) {
throw new IllegalArgumentException("Input must have a valid frame rate");
}
if (grabber.getAudioChannels() > 2) {
throw new IllegalArgumentException("Use mono or stereo audio");
}
if (operation.equals("watermark")) {
try (Size bounds = getTextSize(text, FONT_HERSHEY_SIMPLEX, 1.0, 4, new int[1])) {
if (bounds.width() + 48 > width || height < 72) {
throw new IllegalArgumentException("Watermark does not fit the frame");
}
}
}
try (Size size = new Size(width, height);
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(
output.toString(), width, height, grabber.getAudioChannels())) {
recorder.setFormat("mp4");
recorder.setVideoCodec(AV_CODEC_ID_H264);
recorder.setVideoBitrate(5_000_000);
recorder.setFrameRate(fps);
recorder.setPixelFormat(AV_PIX_FMT_YUV420P);
if (grabber.getAudioChannels() > 0) {
recorder.setAudioCodec(AV_CODEC_ID_AAC);
recorder.setAudioBitrate(128_000);
recorder.setSampleRate(grabber.getSampleRate());
}
recorder.start();
int images = 0;
Frame frame;
while ((frame = grabber.grab()) != null) {
if (frame.image != null) {
Frame image = frame;
if (operation.equals("resize")) {
int interpolation = width < grabber.getImageWidth()
&& height < grabber.getImageHeight() ? INTER_AREA : INTER_LINEAR;
resize(converter.convert(frame), resized, size, 0, 0, interpolation);
image = converter.convert(resized);
} else if (operation.equals("watermark")) {
Mat pixels = converter.convert(frame);
putText(pixels, text, origin, FONT_HERSHEY_SIMPLEX,
1.0, black, 4, LINE_AA, false);
putText(pixels, text, origin, FONT_HERSHEY_SIMPLEX,
1.0, white, 2, LINE_AA, false);
image = converter.convert(pixels);
}
recorder.setTimestamp(frame.timestamp);
recorder.record(image);
images++;
}
if (frame.samples != null) recorder.recordSamples(
frame.sampleRate, frame.audioChannels, frame.samples);
}
if (images == 0) throw new IllegalArgumentException("No video frames decoded");
}
}
completed = true;
} finally {
if (!completed) Files.deleteIfExists(output);
}
}
}
Pour disposer d’une entrée reproductible, générez une mire de test de 640 × 360 avec une tonalité
de 440 Hz d’une durée de 2,36 secondes.
L’option -n refuse d’écraser un input.mp4 existant :
ffmpeg -nostdin -v error -n \
-f lavfi -i 'testsrc2=size=640x360:rate=25' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000' \
-t 2.36 -c:v mpeg4 -q:v 3 -c:a aac input.mp4
Compilez le projet, vérifiez que l’ensemble de l’entrée ne contient pas d’erreurs de décodage, puis transcodez-la :
mvn --batch-mode compile dependency:copy-dependencies &&
ffmpeg -nostdin -v error -xerror -err_detect explode -i ./input.mp4 \
-map 0:v:0 -map '0:a:0?' -f null - &&
java -cp 'target/classes:target/dependency/*' VideoProcessor \
transcode ./input.mp4 ./transcoded.mp4
Maven compile la classe dans target/classes et
copie ses dépendances
dans target/dependency. Conservez les opérateurs && : après un échec de compilation, les anciennes
classes compilées ne doivent pas s’exécuter, et après un échec de la vérification de l’entrée,
aucun résultat ne doit être créé. Le processus Java se termine aussi avec un code non nul en cas
d’erreurs de traitement signalées.
La vérification stricte de l’entrée est importante, car
le grabber de JavaCV peut ignorer certaines erreurs de décodage.
N’utilisez pas le seul appel à la classe Java comme détecteur de corruption.
L’option -xerror de FFmpeg s’arrête en cas d’erreur ; ni cette vérification ni un
encodage réussi ne peuvent prouver qu’une source contient toutes les séquences que vous comptiez
enregistrer.
Redimensionnement de vidéos avec JavaCV
Exécutez l’opération de redimensionnement sur la même entrée :
mvn --batch-mode compile dependency:copy-dependencies &&
ffmpeg -nostdin -v error -xerror -err_detect explode -i ./input.mp4 \
-map 0:v:0 -map '0:a:0?' -f null - &&
java -cp 'target/classes:target/dependency/*' VideoProcessor \
resize ./input.mp4 ./resized.mp4 320 180
Vous obtenez un MP4 de 320 × 180. La largeur et la hauteur doivent toutes deux être positives et paires pour la sortie YUV420P de ce programme. L’opération met directement à l’échelle vers ces dimensions : choisissez donc le même rapport d’aspect que la source pour éviter toute déformation. Elle ne recadre pas l’image et n’ajoute pas de marges.
La fonction resize d’OpenCV utilise ici une interpolation par
zone lorsque les deux dimensions diminuent, et une interpolation linéaire sinon. L’image
redimensionnée passe par le même encodeur que l’opération de transcodage ; les échantillons audio
contournent OpenCV.
Ajout de filigranes aux vidéos
Incrustez un court libellé texte dans chaque image :
mvn --batch-mode compile dependency:copy-dependencies &&
ffmpeg -nostdin -v error -xerror -err_detect explode -i ./input.mp4 \
-map 0:v:0 -map '0:a:0?' -f null - &&
java -cp 'target/classes:target/dependency/*' VideoProcessor \
watermark ./input.mp4 ./watermarked.mp4 'Preview'
Le résultat reste en 640 × 360. Le texte commence à 24 pixels du bord gauche, avec sa ligne de base à 48 pixels du haut. Dessiner d’abord le texte en noir, puis le même texte en blanc légèrement plus fin par-dessus, crée un contour qui reste visible sur les fonds clairs.
La fonction putText d’OpenCV utilise une police
Hershey. Cet exemple accepte l’ASCII imprimable et rejette un texte vide ou un libellé qui ne
tiendrait pas dans l’image. Il n’affiche pas de polices Unicode arbitraires et n’ajoute pas de
piste de sous-titres désactivable.
Test du traitement vidéo
Inspectez les trois fichiers, puis décodez chaque flux vidéo et audio sélectionné. Collez ce bloc dans Bash ; son sous-shell empêche les échecs de fermer votre shell actuel :
(
for output in ./transcoded.mp4 ./resized.mp4 ./watermarked.mp4; do
ffprobe -v error -count_frames \
-show_entries stream=codec_type,codec_name,width,height,sample_rate,channels,nb_read_frames:format=duration \
-of json "$output" || exit 1
ffmpeg -nostdin -v error -xerror -err_detect explode -i "$output" \
-map 0:v:0 -map '0:a:0?' -f null - || exit 1
done
)
Pour l’entrée générée, attendez-vous à une vidéo H.264 avec 59 images décodées dans chaque sortie.
Le transcodage et la version filigranée restent en 640 × 360 ; la version redimensionnée est en
320 × 180. Chaque fichier doit aussi contenir de l’AAC mono à 48 000 Hz et durer environ
2,36 secondes. Le découpage en trames AAC peut ajouter un léger remplissage. ffprobe
affiche ces propriétés pour que vous les compariez ; le bloc ne vérifie pas automatiquement leurs
valeurs.
Lisez les sorties jusqu’à leur dernière image et écoutez la tonalité jusqu’au bout. Vérifiez que la version redimensionnée a les proportions voulues et que Preview n’apparaît que dans la sortie filigranée. Les métadonnées de flux et un fichier non vide ne suffisent pas à établir ces résultats visuels et audio.
Chaque chemin de sortie doit être nouveau. Relancer une opération déclenche une erreur de fichier existant et préserve les octets existants, y compris lorsque la sortie désigne l’entrée elle-même. Une fois que ce programme a créé une destination, un échec signalé la supprime après la fermeture des ressources natives. Un processus tué ou un plantage de la machine peut néanmoins laisser un fichier partiel. Conservez les originaux jusqu’à la fin de la vérification.
Si Maven signale un échec de compilation, corrigez-le avant d’invoquer Java. Une erreur de classe
introuvable signifie généralement que la commande s’exécute en dehors de la racine du projet ou que
la compilation ne s’est pas terminée. En cas d’erreurs de chargement natif, vérifiez l’architecture
et les JAR à classificateur dans target/dependency ; ne mélangez pas
les bindings d’une version de JavaCV avec les bibliothèques natives d’une autre.
Gestion de la mémoire et optimisation des performances
JavaCV réutilise le stockage des images décodées. La boucle consomme chaque image avant de récupérer la suivante, réutilise un seul tampon de redimensionnement et ferme le grabber, l’enregistreur, le convertisseur et les objets OpenCV qu’elle possède avec try-with-resources. Elle ne charge pas une vidéo entière dans le tas Java.
Commencez par une tâche à la fois. Chaque encodeur possède ses propres tampons natifs et peut utiliser plusieurs threads : une tâche par cœur de CPU n’est donc pas une limite de concurrence par défaut pertinente. Mesurez la mémoire et le débit avec vos résolutions réelles avant d’ajouter une file de workers bornée. La vérification de l’entrée décode aussi toute la source : elle ajoute du travail, mais permet de détecter une entrée endommagée avant de l’encoder.
Pour une application qui doit déléguer ses tâches d’encodage, consultez le service d’encodage vidéo de Transloadit. Conservez les mêmes vérifications de sortie lorsque vous remplacez un encodeur local par une chaîne de traitement hébergée.
