Processing videos in Java: transcode, resize, and watermark
JavaCV lets you decode video with FFmpeg, change its frames with OpenCV, and encode an MP4 from Java. This walkthrough builds one command-line program with three operations: transcode to H.264, resize to a chosen resolution, and burn in a text watermark. Each operation retains the first audio track as AAC when one is present.
Prerequisites
Use a Linux x86_64 machine with Bash, JDK 21.0.12.1, and Maven 3.9.16 for the environment tested
here. Install the ffmpeg and ffprobe command-line tools too; the verification commands were tested
with FFmpeg 9.0.1. They run separately from JavaCV’s bundled FFmpeg 7.1.1 libraries.
The example targets readable local, constant-frame-rate SDR videos with square pixels, no rotation, and audio and video starting together at zero. Use one video track and optional mono or stereo audio. It does not preserve subtitles, extra tracks, metadata, HDR, or variable-frame-rate timing. Work in a directory you control and run jobs sequentially.
Create a new project directory in your editor. Inside it, create pom.xml and
src/main/java/VideoProcessor.java. Open a terminal in that project directory; all commands below
run there. Save this complete 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>
These pins keep the Java wrappers and native libraries together. Instead of downloading every JavaCV backend for every platform, this project includes FFmpeg, OpenCV, and their dependencies for Linux x86_64. Bytedeco documents why both the Java artifacts and platform-specific native artifacts are needed.
Transcoding videos in Java
Save the following class as src/main/java/VideoProcessor.java. All three operations share the
same decoder, audio handling, and encoder configuration. Only the image-processing step changes.
The encoder requests 5 Mbps for video; that is a bitrate budget, not a guaranteed output size or quality level. The standard native binaries used here encode H.264 with OpenH264, so the example does not set x264-specific CRF options. Audio is decoded and re-encoded, rather than copied without loss. Use the pinned 1.5.12 release: its recorder flushes delayed audio packets at the end of the file; 1.5.11 can lose the final AAC packet.
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);
}
}
}
For a reproducible input, generate a 640 × 360 test pattern with a 440 Hz tone lasting 2.36 seconds.
The -n option refuses to overwrite an existing input.mp4:
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
Build the project, check the entire input for decoding errors, and transcode it:
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 compiles the class into target/classes and
copies its dependencies
into target/dependency. Keep the && operators: a failed build must not run old compiled classes,
and a failed input check must not create a result. The Java process also exits nonzero on reported
processing errors.
The strict input check matters because
JavaCV’s grabber can ignore some decoding errors.
Do not treat calling the Java class alone as a corruption detector. FFmpeg’s
-xerror option stops on errors; neither that check nor a successful
encode can prove that a source contains all the footage you intended to record.
Resizing videos using JavaCV
Run the resize operation against the same input:
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
This produces a 320 × 180 MP4. Width and height must both be positive and even for this program’s YUV420P output. The operation scales directly to those dimensions, so choose the same aspect ratio as the source to avoid stretching. It does not crop or add padding.
OpenCV’s resize uses area
interpolation here when both dimensions shrink, and linear interpolation otherwise. The resized
image goes to the same encoder as the transcode operation; audio samples bypass OpenCV.
Adding watermarks to videos
Burn a short text label into each frame:
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'
The result stays 640 × 360. The text starts 24 pixels from the left, with its baseline 48 pixels from the top. Drawing black text first and slightly thinner white text over it creates an outline that remains visible on light backgrounds.
OpenCV’s putText uses a Hershey
font. This example accepts printable ASCII and rejects blank text or a label that will not fit.
It does not render arbitrary Unicode fonts or add a removable subtitle track.
Testing video processing
Inspect all three files, then decode every selected video and audio stream. Paste this block into Bash; its subshell keeps failures from exiting your current shell:
(
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
)
For the generated input, expect H.264 video with 59 decoded frames in every output. The transcode
and watermark stay 640 × 360; the resize is 320 × 180. Each file should also contain mono AAC at
48,000 Hz and last about 2.36 seconds. AAC framing can add a small amount of padding. ffprobe
prints these properties for you to compare; the block does not automatically assert their values.
Play the outputs through their final frame and listen through the end of the tone. Check that the resize has the intended proportions and that Preview appears only in the watermark output. Stream metadata and a nonempty file cannot establish those visual and audio results.
Every output path must be new. Running an operation again raises a file-exists error and preserves the existing bytes, including when the output names the input itself. Once this program creates a destination, a reported failure removes it after closing native resources. A killed process or machine crash can still leave a partial file. Keep the originals until verification is complete.
If Maven reports a compilation failure, fix it before invoking Java. A class-not-found error usually
means the command is running outside the project root or the build did not finish. Native loading
errors warrant checking the architecture and the classifier JARs in target/dependency; do not mix
the bindings from one JavaCV release with native libraries from another.
Memory management and performance optimization
JavaCV reuses decoded frame storage. The loop consumes each frame before grabbing the next one, reuses one resize buffer, and closes the grabber, recorder, converter, and owned OpenCV objects with try-with-resources. It does not collect an entire video in the Java heap.
Start with one job at a time. Each encoder has its own native buffers and may use multiple threads, so one job per CPU core is not a useful default concurrency limit. Measure memory and throughput with your actual resolutions before adding a bounded worker queue. The input check also decodes the whole source, adding work in exchange for catching damaged input before encoding it.
For an application that should offload encoding jobs, see Transloadit’s Video Encoding service. Keep the same output checks when replacing a local encoder with a hosted workflow.
