Videos in Java: transkodieren, skalieren, Wasserzeichen
Mit JavaCV können Sie Videos mit FFmpeg decodieren, ihre Frames mit OpenCV bearbeiten und aus Java heraus eine MP4-Datei codieren. Diese Anleitung erstellt ein Kommandozeilenprogramm mit drei Funktionen: Transkodieren nach H.264, Skalieren auf eine gewählte Auflösung und Einbrennen eines Textwasserzeichens. Jede Funktion erhält die erste Audiospur als AAC, sofern eine vorhanden ist.
Voraussetzungen
Verwenden Sie für die hier getestete Umgebung einen Linux-x86_64-Rechner mit Bash, JDK 21.0.12.1
und Maven 3.9.16. Installieren Sie auch die Kommandozeilentools ffmpeg und
ffprobe; die Prüfkommandos wurden mit FFmpeg 9.0.1 getestet. Sie laufen
unabhängig von den Bibliotheken von FFmpeg 7.1.1, die JavaCV mitliefert.
Das Beispiel ist für lesbare lokale SDR-Videos mit konstanter Bildrate, quadratischen Pixeln und ohne Drehung ausgelegt, bei denen Audio und Video gemeinsam bei null starten. Verwenden Sie eine Videospur und optional Mono- oder Stereo-Audio. Untertitel, zusätzliche Spuren, Metadaten, HDR und das Timing variabler Bildraten bleiben nicht erhalten. Arbeiten Sie in einem Verzeichnis, über das Sie die Kontrolle haben, und führen Sie Jobs nacheinander aus.
Erstellen Sie in Ihrem Editor ein neues Projektverzeichnis. Legen Sie darin
pom.xml und src/main/java/VideoProcessor.java an. Öffnen Sie ein Terminal in
diesem Projektverzeichnis; alle folgenden Befehle werden dort ausgeführt. Speichern Sie diese
vollständige Datei pom.xml:
<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>
Diese festen Versionsangaben halten die Java-Wrapper und nativen Bibliotheken aufeinander abgestimmt. Statt jedes JavaCV-Backend für jede Plattform herunterzuladen, enthält dieses Projekt FFmpeg, OpenCV und deren Abhängigkeiten für Linux x86_64. Bytedeco dokumentiert, warum sowohl die Java-Artefakte als auch plattformspezifische native Artefakte benötigt werden.
Videos in Java transkodieren
Speichern Sie die folgende Klasse als src/main/java/VideoProcessor.java. Alle drei Funktionen nutzen
denselben Decoder, dieselbe Audioverarbeitung und dieselbe Encoder-Konfiguration. Nur der
Bildverarbeitungsschritt ändert sich.
Der Encoder fordert 5 Mbps für Video an; das ist ein Bitratenbudget, keine garantierte Ausgabegröße oder Qualitätsstufe. Die hier verwendeten nativen Standard-Binärdateien codieren H.264 mit OpenH264. Daher setzt das Beispiel keine x264-spezifischen CRF-Optionen. Audio wird decodiert und erneut codiert, statt verlustfrei kopiert zu werden. Verwenden Sie die festgelegte Version 1.5.12: Ihr Recorder schreibt verzögerte Audiopakete am Dateiende; bei 1.5.11 kann das letzte AAC-Paket verloren gehen.
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);
}
}
}
Erzeugen Sie als reproduzierbare Eingabe ein Testbild mit 640 × 360 und einem Ton mit 440 Hz
mit einer Dauer von 2,36 Sekunden. Die Option -n verhindert, dass eine
vorhandene Datei input.mp4 überschrieben wird:
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
Kompilieren Sie das Projekt, prüfen Sie die gesamte Eingabe auf Decodierungsfehler und transkodieren Sie sie:
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 kompiliert die Klasse nach target/classes und
kopiert ihre Abhängigkeiten
nach target/dependency. Behalten Sie die Operatoren &&
bei: Nach einem fehlgeschlagenen Build dürfen keine alten kompilierten Klassen ausgeführt werden,
und nach einer fehlgeschlagenen Eingabeprüfung darf kein Ergebnis entstehen. Auch der Java-Prozess
endet bei gemeldeten Verarbeitungsfehlern mit einem Exit-Code ungleich null.
Die strenge Eingabeprüfung ist wichtig, weil
der Grabber von JavaCV manche Decodierungsfehler ignorieren kann.
Verlassen Sie sich nicht allein auf den Aufruf der Java-Klasse, um beschädigte Dateien zu erkennen.
Die FFmpeg-Option -xerror stoppt bei Fehlern;
weder diese Prüfung noch erfolgreiches Encoding können belegen, dass eine Quelle alle Aufnahmen
enthält, die Sie aufzeichnen wollten.
Videogröße mit JavaCV ändern
Führen Sie die Größenänderung mit derselben Eingabe aus:
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
So entsteht eine MP4-Datei mit 320 × 180. Breite und Höhe müssen für die YUV420P-Ausgabe dieses Programms beide positiv und gerade sein. Die Funktion skaliert direkt auf diese Abmessungen. Wählen Sie daher dasselbe Seitenverhältnis wie bei der Quelle, um Verzerrungen zu vermeiden. Das Bild wird weder zugeschnitten noch durch Ränder ergänzt.
Die OpenCV-Funktion resize nutzt hier
Flächeninterpolation, wenn beide Abmessungen kleiner werden, andernfalls lineare Interpolation.
Das skalierte Bild geht an denselben Encoder wie beim Transkodieren; Audiosamples durchlaufen
OpenCV nicht.
Wasserzeichen in Videos einfügen
Brennen Sie einen kurzen Text in jeden Frame ein:
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'
Das Ergebnis bleibt bei 640 × 360. Der Text beginnt 24 Pixel vom linken Rand entfernt; seine Grundlinie liegt 48 Pixel unterhalb des oberen Rands. Zuerst wird schwarzer Text gezeichnet, darüber etwas dünnerer weißer Text. So entsteht eine Kontur, die auf hellen Hintergründen sichtbar bleibt.
Die OpenCV-Funktion putText verwendet eine
Hershey-Schrift. Dieses Beispiel akzeptiert druckbare ASCII-Zeichen und weist leeren Text sowie
Beschriftungen zurück, die nicht ins Bild passen. Es rendert keine beliebigen Unicode-Schriften
und fügt keine entfernbare Untertitelspur hinzu.
Videoverarbeitung testen
Untersuchen Sie alle drei Dateien und decodieren Sie anschließend jeden ausgewählten Video- und Audiostream. Fügen Sie diesen Block in Bash ein; seine Subshell verhindert, dass Fehler Ihre aktuelle Shell beenden:
(
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
)
Für die erzeugte Eingabe ist in jeder Ausgabe H.264-Video mit 59 decodierten Frames zu erwarten.
Die transkodierte Ausgabe und die Ausgabe mit Wasserzeichen bleiben bei 640 × 360;
die skalierte Ausgabe hat 320 × 180. Jede Datei sollte außerdem Mono-AAC mit 48.000 Hz
enthalten und etwa 2,36 Sekunden dauern. Die AAC-Frame-Struktur kann etwas Padding hinzufügen.
ffprobe gibt diese Eigenschaften zum Vergleich aus; der Block prüft ihre
Werte nicht automatisch.
Spielen Sie die Ausgaben bis zum letzten Frame ab und hören Sie bis zum Ende des Tons zu. Prüfen Sie, ob die skalierte Ausgabe die gewünschten Proportionen hat und ob Preview nur in der Ausgabe mit Wasserzeichen erscheint. Stream-Metadaten und eine nicht leere Datei können diese Bild- und Audioergebnisse nicht belegen.
Jeder Ausgabepfad muss neu sein. Wird eine Funktion erneut ausgeführt, meldet das Programm, dass die Datei bereits existiert, und erhält deren Bytes. Das gilt auch, wenn als Ausgabe die Eingabe selbst angegeben ist. Sobald dieses Programm eine Zieldatei erstellt hat, entfernt es diese bei einem gemeldeten Fehler, nachdem es die nativen Ressourcen geschlossen hat. Ein beendeter Prozess oder ein Rechnerabsturz kann dennoch eine unvollständige Datei hinterlassen. Bewahren Sie die Originale auf, bis die Prüfung abgeschlossen ist.
Wenn Maven einen Kompilierungsfehler meldet, beheben Sie ihn, bevor Sie Java aufrufen. Ein Fehler
wegen einer nicht gefundenen Klasse bedeutet meist, dass der Befehl außerhalb des
Projektstammverzeichnisses ausgeführt wird oder der Build nicht abgeschlossen wurde. Prüfen Sie
bei Fehlern beim Laden nativer Bibliotheken die Architektur und die Classifier-JARs in
target/dependency. Mischen Sie keine Bindings einer JavaCV-Version mit nativen
Bibliotheken einer anderen Version.
Speicherverwaltung und Leistungsoptimierung
JavaCV verwendet den Speicher decodierter Frames wieder. Die Schleife verarbeitet jeden Frame, bevor sie den nächsten abruft, nutzt einen einzigen Puffer für die Größenänderung wieder und schließt Grabber, Recorder, Konverter sowie die ihr gehörenden OpenCV-Objekte mit try-with-resources. Sie sammelt kein vollständiges Video im Java-Heap.
Beginnen Sie mit jeweils einem Job. Jeder Encoder hat eigene native Puffer und kann mehrere Threads verwenden. Ein Job pro CPU-Kern ist daher kein sinnvoller Standardwert für die maximale Parallelität. Messen Sie Speicherbedarf und Durchsatz mit Ihren tatsächlichen Auflösungen, bevor Sie eine begrenzte Worker-Queue hinzufügen. Auch die Eingabeprüfung decodiert die gesamte Quelle. Das verursacht zusätzlichen Aufwand, erkennt dafür aber beschädigte Eingaben vor dem Encoding.
Wenn Ihre Anwendung Encoding-Jobs auslagern soll, sehen Sie sich den Video-Encoding-Service von Transloadit an. Behalten Sie dieselben Ausgabeprüfungen bei, wenn Sie einen lokalen Encoder durch einen gehosteten Workflow ersetzen.
