Vídeos em Java: transcodificar, redimensionar e pôr marca-d’água
O JavaCV permite decodificar vídeo com o FFmpeg, alterar seus quadros com o OpenCV e codificar um MP4 a partir de Java. Este passo a passo cria um único programa de linha de comando com três operações: transcodificar para H.264, redimensionar para uma resolução escolhida e gravar uma marca-d’água de texto nos quadros. Cada operação mantém a primeira faixa de áudio como AAC, quando houver uma.
Pré-requisitos
Para o ambiente testado aqui, use uma máquina Linux x86_64 com Bash, JDK 21.0.12.1 e Maven 3.9.16.
Instale também as ferramentas de linha de comando ffmpeg e ffprobe; os comandos de verificação foram
testados com o FFmpeg 9.0.1. Eles são executados separadamente das bibliotecas do FFmpeg 7.1.1
incluídas no JavaCV.
O exemplo é voltado para vídeos SDR locais e legíveis, com taxa de quadros constante, pixels quadrados, sem rotação e com áudio e vídeo começando juntos no zero. Use uma faixa de vídeo e, opcionalmente, áudio mono ou estéreo. Ele não preserva legendas, faixas extras, metadados, HDR nem a temporização de taxa de quadros variável. Trabalhe em um diretório que você controle e execute as tarefas em sequência.
Crie um novo diretório de projeto no seu editor. Dentro dele, crie pom.xml e
src/main/java/VideoProcessor.java. Abra um terminal nesse diretório do projeto; todos os comandos abaixo
são executados ali. Salve 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>
Essas versões fixadas mantêm juntos os wrappers Java e as bibliotecas nativas. Em vez de baixar todos os backends do JavaCV para todas as plataformas, este projeto inclui o FFmpeg, o OpenCV e suas dependências para Linux x86_64. A Bytedeco documenta por que são necessários tanto os artefatos Java quanto os artefatos nativos específicos de cada plataforma.
Transcodificação de vídeos em Java
Salve a classe a seguir como src/main/java/VideoProcessor.java. As três operações compartilham o mesmo
decodificador, o mesmo tratamento de áudio e a mesma configuração do codificador. Só a etapa de
processamento de imagem muda.
O codificador solicita 5 Mbps para o vídeo; isso é um orçamento de bitrate, não um tamanho de saída nem um nível de qualidade garantidos. Os binários nativos padrão usados aqui codificam H.264 com o OpenH264, então o exemplo não define opções de CRF específicas do x264. O áudio é decodificado e recodificado, em vez de copiado sem perdas. Use a versão fixada 1.5.12: o recorder dessa versão descarrega os pacotes de áudio atrasados no fim do arquivo; a 1.5.11 pode perder o último pacote 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 ter uma entrada reproduzível, gere um padrão de teste de 640 × 360 com um tom de 440 Hz de
2,36 segundos. A opção -n se recusa a sobrescrever um 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
Compile o projeto, verifique se há erros de decodificação em toda a entrada e faça a transcodificação:
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
O Maven compila a classe em target/classes e
copia suas dependências
para target/dependency. Mantenha os operadores &&: um build com falha não deve executar classes
compiladas antigas, e uma verificação de entrada com falha não deve criar um resultado. O processo
Java também termina com código de saída diferente de zero quando erros de processamento são
relatados.
A verificação rigorosa da entrada é importante porque
o grabber do JavaCV pode ignorar alguns erros de decodificação.
Não trate a simples chamada da classe Java como um detector de corrupção. A
opção -xerror do FFmpeg interrompe a execução quando há erros; nem essa verificação
nem uma codificação bem-sucedida podem provar que uma fonte contém todas as imagens que você
pretendia gravar.
Redimensionamento de vídeos com JavaCV
Execute a operação de redimensionamento na mesma 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
Isso gera um MP4 de 320 × 180. A largura e a altura precisam ser positivas e pares para a saída YUV420P deste programa. A operação redimensiona diretamente para essas dimensões, então escolha a mesma proporção da fonte para evitar distorções. Ela não recorta nem adiciona preenchimento.
O resize do OpenCV usa interpolação por área aqui quando as duas dimensões
diminuem e interpolação linear nos outros casos. A imagem redimensionada vai para o mesmo
codificador da operação de transcodificação; as amostras de áudio não passam pelo OpenCV.
Inclusão de marcas-d’água em vídeos
Grave um rótulo de texto curto em cada quadro:
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'
O resultado continua em 640 × 360. O texto começa a 24 pixels da esquerda, com a linha de base a 48 pixels do topo. Desenhar primeiro o texto em preto e, por cima dele, um texto branco um pouco mais fino cria um contorno que continua visível em fundos claros.
O putText do OpenCV usa uma fonte Hershey.
Este exemplo aceita ASCII imprimível e rejeita texto em branco ou um rótulo que não caiba. Ele não
renderiza fontes Unicode arbitrárias nem adiciona uma faixa de legenda removível.
Teste do processamento de vídeo
Inspecione os três arquivos e depois decodifique todos os streams de vídeo e áudio selecionados. Cole este bloco no Bash; o subshell dele impede que falhas encerrem o seu shell atual:
(
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 a entrada gerada, espere vídeo H.264 com 59 quadros decodificados em cada saída. As saídas de
transcodificação e de marca-d’água permanecem em 640 × 360; a de redimensionamento tem 320 × 180.
Cada arquivo também deve conter AAC mono a 48.000 Hz e durar cerca de 2,36 segundos. O
enquadramento AAC pode adicionar um pequeno preenchimento. O ffprobe
exibe essas propriedades para você comparar; o bloco não verifica os valores automaticamente.
Reproduza as saídas até o último quadro e ouça o tom até o fim. Verifique se o redimensionamento tem as proporções desejadas e se Preview aparece apenas na saída com marca-d’água. Os metadados do stream e um arquivo não vazio não conseguem comprovar esses resultados visuais e sonoros.
Todo caminho de saída precisa ser novo. Executar uma operação de novo gera um erro de arquivo existente e preserva os bytes atuais, inclusive quando a saída tem o nome da própria entrada. Depois que este programa cria um destino, uma falha relatada o remove após fechar os recursos nativos. Um processo encerrado à força ou uma falha da máquina ainda pode deixar um arquivo parcial. Mantenha os originais até concluir a verificação.
Se o Maven relatar uma falha de compilação, corrija-a antes de chamar o Java. Um erro de classe não
encontrada geralmente significa que o comando está sendo executado fora da raiz do projeto ou que o
build não terminou. Erros de carregamento nativo exigem verificar a arquitetura e os JARs com
classificador em target/dependency; não misture os bindings de uma versão do JavaCV com
as bibliotecas nativas de outra.
Gerenciamento de memória e otimização de desempenho
O JavaCV reutiliza o armazenamento dos quadros decodificados. O loop consome cada quadro antes de capturar o próximo, reutiliza um único buffer de redimensionamento e fecha o grabber, o recorder, o conversor e os objetos do OpenCV sob sua responsabilidade com try-with-resources. Ele não acumula um vídeo inteiro no heap do Java.
Comece com uma tarefa por vez. Cada codificador tem seus próprios buffers nativos e pode usar várias threads, então uma tarefa por núcleo de CPU não é um limite de concorrência padrão útil. Meça a memória e a vazão com as suas resoluções reais antes de adicionar uma fila de workers com limite. A verificação de entrada também decodifica a fonte inteira, o que adiciona trabalho, mas em troca detecta entradas danificadas antes de codificá-las.
Para uma aplicação que deve delegar Jobs de codificação, consulte o serviço de codificação de vídeo da Transloadit. Mantenha as mesmas verificações de saída ao substituir um codificador local por um fluxo de trabalho hospedado.
