Concurrent video watermarking with Rust & FFmpeg
Use Rust to run a small batch of FFmpeg watermarking jobs, with at most two child processes at a time. The tool below adds a PNG to each video, retains its first audio track, and returns a nonzero exit status if any job fails. Successful videos remain available even when another job fails.
Prerequisites
- Bash on Linux for the commands below.
- Rust 1.85.1 or newer, with
rustcon yourPATH. - FFmpeg and ffprobe 6.1.1 or newer, with the
libx264encoder and theoverlay,color, andsinefilters. The fixture commands also need the AAC and PNG encoders.
The example uses MP4 videos with an unrotated, even-sized video stream and optional AAC audio, plus an 8-bit PNG watermark small enough to fit inside each frame. It processes the first video and first audio stream only, not subtitles or extra tracks. The shell workflow is Linux-specific; it is not a tested Windows or macOS installation guide.
Bound the number of FFmpeg processes
Rust handles scheduling and exit statuses; FFmpeg handles decoding, compositing, and encoding. There are no FFmpeg bindings or external Rust crates. Two jobs run together, then both finish before the next group starts. This deliberately simple grouping can leave a slot idle while the slower job finishes. It is a concurrency limit, not a throughput benchmark.
Two child processes do not mean two CPU threads. The code requests one codec thread for each input and the video encoder, and one thread for the complex filter graph. FFmpeg can still create other internal threads. These settings do not impose a total CPU or memory limit; see the FFmpeg option reference.
Environment setup
Install a maintained stable Rust toolchain through rustup. On Ubuntu or Debian, install the distribution’s FFmpeg package:
sudo apt-get update && sudo apt-get install -y ffmpeg
Check rustc --version, ffmpeg -version, and ffprobe -version. This example is tested with
Rust 1.85.1 and 1.98.1, and FFmpeg 6.1.1 and 9.0.1. Inspect ffmpeg -encoders and
ffmpeg -filters if your build is missing a prerequisite.
Create a new directory from your current working directory. If it already exists, choose another name rather than deleting it:
mkdir rust-watermark
Save the complete source below as rust-watermark/watermark.rs. We compile this standalone file
with rustc, specifying the edition and executable path explicitly. No Cargo project is created,
so an enclosing Cargo workspace or its configured target directory does not control this build.
Building a basic watermarking tool
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::thread;
const MAX_CHILDREN: usize = 2;
fn watermark_video(input: &Path, watermark: &Path, output: &Path) -> io::Result<()> {
let input = fs::canonicalize(input)?;
let result = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-xerror"])
.args(["-threads", "1", "-i"])
.arg(input)
.args(["-threads", "1", "-f", "image2", "-pattern_type", "none", "-i"])
.arg(watermark)
.args([
"-filter_complex_threads", "1",
"-filter_complex", "[0:v:0][1:v:0]overlay=10:10:eof_action=repeat:repeatlast=1[v]",
"-map", "[v]", "-map", "0:a:0?",
"-c:v", "libx264", "-threads:v", "1", "-crf", "20",
"-pix_fmt", "yuv420p", "-c:a", "copy",
"-movflags", "+faststart", "-f", "mp4",
])
.arg(output)
.stdin(Stdio::null())
.stdout(Stdio::null())
.output()?;
if result.status.success() && result.stderr.is_empty() {
return Ok(());
}
if output.exists() {
fs::remove_file(output)?;
}
io::stderr().write_all(&result.stderr)?;
Err(io::Error::other(format!("FFmpeg failed ({})", result.status)))
}
fn run() -> io::Result<ExitCode> {
let arguments: Vec<_> = env::args_os().skip(1).collect();
if arguments.len() < 3 {
return Err(io::Error::other(
"Usage: watermark-batch WATERMARK.png NEW_OUTPUT_DIR INPUT.mp4 [INPUT.mp4 ...]",
));
}
let watermark = fs::canonicalize(&arguments[0])?;
let output_directory = PathBuf::from(&arguments[1]);
fs::create_dir(&output_directory)?;
let mut next_job = 1;
let mut succeeded = 0;
let mut failed = 0;
for group in arguments[2..].chunks(MAX_CHILDREN) {
let mut handles = Vec::new();
for input in group {
let input = PathBuf::from(input);
let output = output_directory.join(format!("job-{next_job}.mp4"));
next_job += 1;
let job_input = input.clone();
let job_output = output.clone();
let job_watermark = watermark.clone();
let handle = thread::spawn(move || {
watermark_video(&job_input, &job_watermark, &job_output)
});
handles.push((input, output, handle));
}
for (input, output, handle) in handles {
match handle.join() {
Ok(Ok(())) => {
succeeded += 1;
println!("OK {} -> {}", input.display(), output.display());
}
outcome => {
failed += 1;
match outcome {
Ok(Err(error)) => eprintln!("FAIL {}: {error}", input.display()),
_ => eprintln!("FAIL {}: worker panicked", input.display()),
}
}
}
}
}
eprintln!("Batch: {succeeded} succeeded, {failed} failed");
Ok(if failed == 0 { ExitCode::SUCCESS } else { ExitCode::FAILURE })
}
fn main() -> ExitCode {
match run() {
Ok(status) => status,
Err(error) => {
eprintln!("Batch setup failed: {error}");
ExitCode::FAILURE
}
}
}
The overlay filter places the watermark’s top-left
corner 10 pixels from the video’s top and left edges. Repeating the PNG’s last frame keeps it
visible after that single-frame input ends. Video is re-encoded as H.264 with yuv420p; the
optional 0:a:0? mapping copies the first audio track without re-encoding it. Absolute input
paths avoid interpreting leading hyphens as options, and -pattern_type none treats the PNG name
literally, including % characters.
Create two sample videos and a watermark
From the directory containing rust-watermark, paste this block. It creates a 2.32-second blue
video with a 440 Hz tone, a red video with an 880 Hz tone, and a white 48 × 24 pixel PNG. The
subshell keeps your current directory and shell options unchanged, including on failure. The
guard rejects existing fixture names before generating any files. -n is an additional overwrite
check, but FFmpeg can report an overwrite refusal with status 0, so it is not the setup guard.
Use a fresh project directory for another complete replay.
(
set -eu
cd rust-watermark
for fixture in video1.mp4 video2.mp4 watermark.png; do
if [ -e "$fixture" ] || [ -L "$fixture" ]; then
printf 'Fixture already exists: %s\n' "$fixture" >&2
exit 1
fi
done
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=blue:s=320x180:r=25:d=2.32' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000:duration=2.32' \
-c:v libx264 -threads 1 -pix_fmt yuv420p -c:a aac -shortest video1.mp4
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=red:s=400x240:r=25:d=2.32' \
-f lavfi -i 'sine=frequency=880:sample_rate=48000:duration=2.32' \
-c:v libx264 -threads 1 -pix_fmt yuv420p -c:a aac -shortest video2.mp4
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=white:s=48x24:r=1:d=1' \
-frames:v 1 -c:v png -threads 1 -f image2 -update 1 watermark.png
)
Build and run the batch
Still from the parent directory, compile the saved source and run that exact executable:
(
cd rust-watermark &&
rustc --edition=2021 watermark.rs -o watermark-batch &&
./watermark-batch ./watermark.png ./output ./video1.mp4 ./video2.mp4
)
The && chain prevents running an old executable after a failed compilation. Outputs are
rust-watermark/output/job-1.mp4 and rust-watermark/output/job-2.mp4, numbered in input order.
The batch prints Batch: 2 succeeded, 0 failed and exits with
status 0. Play both files: their dimensions, background colors, and tones should remain distinct,
with the white rectangle at (10, 10) throughout each video.
The output directory must not already exist. A rerun using ./output fails before launching any
FFmpeg children and leaves the previous files unchanged. Use a new output directory for another
batch, and do not let another process write into it. Successful files are visible while the batch
runs; wait for its final status before consuming them. This is not an atomic publication scheme.
Error handling and logging
Command::output()
waits for each FFmpeg child and captures its diagnostics. A launch error, a nonzero exit status,
or any stderr text with -loglevel error makes the job fail. The last check matters: FFmpeg 6.1.1
can report a decoder error yet return status 0, even with -xerror.
The runner joins every worker in a group, records both outcomes, and keeps processing later groups. The accumulated failure count determines the final status, even when the last job succeeds.
Try a missing input between the two valid videos, using another new output directory:
(
cd rust-watermark &&
./watermark-batch ./watermark.png ./mixed-output ./video1.mp4 ./missing.mp4 ./video2.mp4
)
This prints Batch: 2 succeeded, 1 failed and exits with status 1.
mixed-output/job-1.mp4 and mixed-output/job-3.mp4 remain; there is no successful second output.
If FFmpeg fails or reports an error after creating a partial file, the worker removes that file.
A removal error is reported as a job failure and may leave the partial file for manual cleanup.
Setup failures, such as a missing watermark or an existing output directory, happen before any
jobs start and do not remove previous results.
Common issues and solutions
- FFmpeg is unavailable: the affected jobs fail to launch. Check the
PATHinherited by the Rust executable, not just your interactive shell. - A codec or filter is missing: inspect the error on stderr and the installed encoders and filters. Concurrent FFmpeg diagnostics can interleave; the Rust messages name each input.
- A job stalls: there is no timeout or cancellation handler here. On an ordinary completed run, the children have exited and the workers have been joined. Killing Rust does not guarantee child termination or partial-file cleanup; a service needs a separate supervision policy.
- A damaged file still produces output: the runner rejects reported errors, but a decoder can conceal damage without reporting one. A successful job is not proof that its source was intact. Inspect the actual video and audio before relying on the result.
- Diagnostics consume memory: this small example captures each child’s error output in memory. It does not bound log size. A service needs bounded or streamed diagnostics as well as process supervision.
Keep this as a local batch runner, not an upload service. For a larger queue, measure resource use
before changing MAX_CHILDREN, and decide how callers handle retained successes and failed jobs.
Rust’s concurrency does not itself establish faster processing or production scalability.
