Videos en Java: transcodificación, escalado y marcas de agua
JavaCV permite decodificar video con FFmpeg, modificar sus fotogramas con OpenCV y codificar un MP4 desde Java. En este tutorial crearás un programa de línea de comandos con tres operaciones: transcodificar a H.264, escalar a una resolución elegida e incrustar una marca de agua de texto. Cada operación conserva la primera pista de audio como AAC cuando está presente.
Requisitos previos
Usa una máquina Linux x86_64 con Bash, JDK 21.0.12.1 y Maven 3.9.16 para reproducir el entorno probado
aquí. Instala también las herramientas de línea de comandos ffmpeg y
ffprobe; los comandos de verificación se probaron con FFmpeg 9.0.1. Se ejecutan
por separado de las bibliotecas de FFmpeg 7.1.1 incluidas con JavaCV.
El ejemplo está pensado para videos SDR locales que se puedan leer, con frecuencia de fotogramas constante, píxeles cuadrados, sin rotación y con audio y video que comiencen juntos en cero. Usa una pista de video y, opcionalmente, audio mono o estéreo. No conserva subtítulos, pistas adicionales, metadatos, HDR ni la temporización de una frecuencia de fotogramas variable. Trabaja en un directorio que controles y ejecuta los trabajos de forma secuencial.
Crea un nuevo directorio de proyecto en tu editor. Dentro de él, crea pom.xml y
src/main/java/VideoProcessor.java. Abre una terminal en ese directorio de proyecto; todos los comandos
siguientes se ejecutan allí. Guarda este pom.xml completo:
<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>
Estas versiones fijas mantienen sincronizadas las bibliotecas contenedoras de Java y las bibliotecas nativas. En lugar de descargar todos los backends de JavaCV para cada plataforma, este proyecto incluye FFmpeg, OpenCV y sus dependencias para Linux x86_64. Bytedeco documenta por qué se necesitan tanto los artefactos de Java como los artefactos nativos específicos de la plataforma.
Transcodificación de videos en Java
Guarda la siguiente clase como src/main/java/VideoProcessor.java. Las tres operaciones comparten el mismo
decodificador, el manejo del audio y la configuración del codificador. Solo cambia el paso de
procesamiento de imágenes.
El codificador solicita 5 Mbps para el video; es un presupuesto de tasa de bits, no una garantía de tamaño de salida ni de nivel de calidad. Los binarios nativos estándar usados aquí codifican H.264 con OpenH264, por lo que el ejemplo no establece opciones CRF específicas de x264. El audio se decodifica y se vuelve a codificar, en lugar de copiarse sin pérdidas. Usa la versión fija 1.5.12: su grabador vacía los paquetes de audio pendientes al final del archivo; la versión 1.5.11 puede perder el último paquete 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);
}
}
}
Para obtener una entrada reproducible, genera un patrón de prueba de 640 × 360 con un tono de 440 Hz
que dure 2,36 segundos. La opción -n impide sobrescribir un
input.mp4 existente:
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
Compila el proyecto, comprueba toda la entrada en busca de errores de decodificación y transcodifícala:
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 compila la clase en target/classes y
copia sus dependencias
en target/dependency. Conserva los operadores &&: una
compilación fallida no debe ejecutar clases compiladas antiguas, y una comprobación fallida de la
entrada no debe crear un resultado. El proceso de Java también termina con un código distinto de
cero cuando se notifican errores de procesamiento.
La comprobación estricta de la entrada es importante porque
el capturador de JavaCV puede ignorar algunos errores de decodificación.
No consideres que llamar únicamente a la clase de Java sirva para detectar datos dañados. La
opción -xerror de FFmpeg detiene la ejecución
ante errores; ni esa comprobación ni un encoding correcto pueden demostrar que la fuente contiene
todo el material que querías grabar.
Escalado de videos con JavaCV
Ejecuta la operación de escalado sobre la misma entrada:
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
Esto produce un MP4 de 320 × 180. Tanto el ancho como el alto deben ser positivos y pares para la salida YUV420P de este programa. La operación escala directamente a esas dimensiones, así que elige la misma relación de aspecto que la fuente para evitar deformaciones. No recorta ni añade relleno.
resize de OpenCV usa aquí interpolación por
área cuando ambas dimensiones se reducen e interpolación lineal en los demás casos. La imagen
escalada pasa al mismo codificador que la operación de transcodificación; las muestras de audio no
pasan por OpenCV.
Añadir marcas de agua a videos
Incrusta una etiqueta de texto breve en cada fotograma:
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'
El resultado se mantiene en 640 × 360. El texto comienza a 24 píxeles del borde izquierdo, con su línea base a 48 píxeles del borde superior. Dibujar primero texto negro y luego texto blanco un poco más fino encima crea un contorno que sigue siendo visible sobre fondos claros.
La función putText de OpenCV usa una fuente
Hershey. Este ejemplo acepta ASCII imprimible y rechaza texto en blanco o una etiqueta que no quepa.
No renderiza fuentes Unicode arbitrarias ni añade una pista de subtítulos que se pueda quitar.
Pruebas del procesamiento de video
Inspecciona los tres archivos y luego decodifica cada flujo de video y audio seleccionado. Pega este bloque en Bash; su subshell evita que los fallos cierren tu shell actual:
(
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
)
Para la entrada generada, cada salida debería contener video H.264 con 59 fotogramas decodificados.
La transcodificación y la marca de agua mantienen 640 × 360; el escalado produce 320 × 180. Cada archivo
también debería contener AAC mono a 48.000 Hz y durar unos 2,36 segundos. La organización del audio AAC
en tramas puede añadir una pequeña cantidad de relleno. ffprobe muestra estas
propiedades para que las compares; el bloque no verifica automáticamente sus valores.
Reproduce las salidas hasta su último fotograma y escucha hasta el final del tono. Comprueba que el escalado tenga las proporciones previstas y que Preview aparezca solo en la salida con marca de agua. Los metadatos de los flujos y un archivo no vacío no bastan para confirmar esos resultados visuales y sonoros.
Cada ruta de salida debe ser nueva. Volver a ejecutar una operación genera un error de archivo existente y conserva los bytes existentes, incluso cuando la salida señala el propio archivo de entrada. Una vez que este programa crea un destino, un fallo notificado lo elimina después de cerrar los recursos nativos. Un proceso terminado de forma forzada o un fallo de la máquina aún pueden dejar un archivo parcial. Conserva los originales hasta completar la verificación.
Si Maven notifica un fallo de compilación, resuélvelo antes de invocar Java. Un error de clase no
encontrada suele significar que el comando se está ejecutando fuera de la raíz del proyecto o que la
compilación no terminó. Los errores de carga nativa requieren comprobar la arquitectura y los JAR con
clasificador en target/dependency; no mezcles los bindings de una versión de JavaCV con
bibliotecas nativas de otra.
Gestión de memoria y optimización del rendimiento
JavaCV reutiliza el almacenamiento de los fotogramas decodificados. El bucle consume cada fotograma antes de capturar el siguiente, reutiliza un único búfer de escalado y cierra el capturador, el grabador, el conversor y los objetos de OpenCV que gestiona mediante try-with-resources. No acumula un video completo en el heap de Java.
Comienza con un solo trabajo a la vez. Cada codificador tiene sus propios búferes nativos y puede usar varios hilos, por lo que un trabajo por núcleo de CPU no es un límite de concurrencia predeterminado útil. Mide el uso de memoria y el rendimiento con las resoluciones que realmente usas antes de añadir una cola de trabajadores con capacidad limitada. La comprobación de la entrada también decodifica toda la fuente, lo que añade trabajo a cambio de detectar una entrada dañada antes de codificarla.
Para una aplicación que necesite delegar los Encoding Jobs, consulta el servicio de encoding de video de Transloadit. Mantén las mismas comprobaciones de salida al sustituir un codificador local por un flujo de trabajo alojado.
