Marcas de agua simultáneas en videos con Rust y FFmpeg
Usa Rust para ejecutar un pequeño lote de tareas de marcas de agua con FFmpeg, con un máximo de dos procesos secundarios a la vez. La herramienta siguiente añade un PNG a cada video, conserva su primera pista de audio y devuelve un estado de salida distinto de cero si alguna tarea falla. Los videos procesados correctamente siguen disponibles aunque falle otra tarea.
Requisitos previos
- Bash en Linux para los comandos siguientes.
- Rust 1.85.1 o posterior, con
rustcen tuPATH. - FFmpeg y ffprobe 6.1.1 o posteriores, con el codificador
libx264y los filtrosoverlay,colorysine. Los comandos para generar los archivos de prueba también necesitan los codificadores AAC y PNG.
El ejemplo usa videos MP4 con un flujo de video sin rotación y de dimensiones pares, y audio AAC opcional, además de una marca de agua PNG de 8 bits lo bastante pequeña para caber dentro de cada fotograma. Solo procesa el primer flujo de video y el primero de audio, no los subtítulos ni las pistas adicionales. El flujo de trabajo de shell es específico de Linux; no es una guía de instalación probada para Windows o macOS.
Limita la cantidad de procesos de FFmpeg
Rust gestiona la planificación y los estados de salida; FFmpeg se encarga de la decodificación, la composición y la codificación. No se usan bindings de FFmpeg ni crates externos de Rust. Se ejecutan dos tareas a la vez y ambas terminan antes de que comience el siguiente grupo. Esta agrupación, deliberadamente sencilla, puede dejar una plaza inactiva mientras termina la tarea más lenta. Es un límite de concurrencia, no una prueba de rendimiento.
Dos procesos secundarios no equivalen a dos hilos de CPU. El código solicita un hilo de códec para cada entrada y para el codificador de video, y un hilo para el grafo de filtros complejo. FFmpeg aún puede crear otros hilos internos. Estos ajustes no imponen un límite total de CPU ni de memoria; consulta la referencia de opciones de FFmpeg.
Configuración del entorno
Instala una cadena de herramientas estable de Rust que siga recibiendo mantenimiento mediante rustup. En Ubuntu o Debian, instala el paquete de FFmpeg de la distribución:
sudo apt-get update && sudo apt-get install -y ffmpeg
Comprueba rustc --version, ffmpeg -version y ffprobe -version.
Este ejemplo se ha probado con Rust 1.85.1 y 1.98.1, y FFmpeg 6.1.1 y 9.0.1.
Inspecciona ffmpeg -encoders y ffmpeg -filters si tu compilación no cumple
alguno de los requisitos previos.
Crea un directorio nuevo desde tu directorio de trabajo actual. Si ya existe, elige otro nombre en lugar de eliminarlo:
mkdir rust-watermark
Guarda todo el código fuente siguiente como rust-watermark/watermark.rs. Compilamos este archivo
independiente con rustc, especificando explícitamente la edición y la ruta
del ejecutable. No se crea ningún proyecto de Cargo, por lo que ni un espacio de trabajo de Cargo
que lo contenga ni su directorio de destino configurado controlan esta compilación.
Construcción de una herramienta básica de marcas de agua
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
}
}
}
El filtro overlay coloca la esquina superior izquierda de la marca
de agua a 10 píxeles de los bordes superior e izquierdo del video. La repetición del último
fotograma del PNG lo mantiene visible después de que termine esa entrada de un solo fotograma.
El video se vuelve a codificar como H.264 con yuv420p; la asignación opcional
0:a:0? copia la primera pista de audio sin volver a codificarla. Las rutas
absolutas de entrada evitan que los guiones iniciales se interpreten como opciones, y
-pattern_type none trata el nombre del PNG de forma literal, incluidos los caracteres
%.
Crea dos videos de muestra y una marca de agua
Desde el directorio que contiene rust-watermark, pega este bloque. Crea un video
azul de 2,32 segundos con un tono de 440 Hz, un video rojo con un tono de 880 Hz y un PNG blanco
de 48 × 24 píxeles. El subshell mantiene sin cambios tu directorio actual y las opciones del shell,
incluso en caso de fallo. La protección rechaza los nombres de archivos de prueba existentes antes
de generar cualquier archivo. -n es una comprobación adicional contra
sobrescrituras, pero FFmpeg puede informar que rechaza una sobrescritura con estado 0, por lo que
no sirve como protección para la preparación. Usa un directorio de proyecto nuevo para repetir
todo el procedimiento.
(
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
)
Compila y ejecuta el lote
Todavía desde el directorio superior, compila el código fuente guardado y ejecuta exactamente ese ejecutable:
(
cd rust-watermark &&
rustc --edition=2021 watermark.rs -o watermark-batch &&
./watermark-batch ./watermark.png ./output ./video1.mp4 ./video2.mp4
)
La cadena && evita que se ejecute un ejecutable anterior después de una
compilación fallida. Los archivos de salida son rust-watermark/output/job-1.mp4 y
rust-watermark/output/job-2.mp4, numerados en el orden de las entradas.
El lote muestra Batch: 2 succeeded, 0 failed y termina con
estado 0. Reproduce ambos archivos: sus dimensiones, colores de fondo y tonos deberían seguir
siendo distintos, con el rectángulo blanco en (10, 10) durante todo el video.
El directorio de salida no debe existir previamente. Una nueva ejecución con
./output falla antes de iniciar cualquier proceso secundario de FFmpeg y deja
los archivos anteriores sin cambios. Usa un directorio de salida nuevo para otro lote y no
permitas que otro proceso escriba en él. Los archivos procesados correctamente son visibles
mientras se ejecuta el lote; espera a que termine y devuelva su estado final antes de utilizarlos.
Este no es un esquema de publicación atómica.
Manejo de errores y registro
Command::output()
espera a que termine cada proceso secundario de FFmpeg y captura sus diagnósticos. Un error al
iniciarlo, un estado de salida distinto de cero o cualquier texto en stderr con
-loglevel error hacen que la tarea falle. La última comprobación es importante:
FFmpeg 6.1.1 puede informar de un error del decodificador y aun así devolver estado 0, incluso con
-xerror.
El ejecutor espera a que termine cada hilo de trabajo de un grupo, registra ambos resultados y sigue procesando los grupos posteriores. El recuento acumulado de fallos determina el estado final, incluso cuando la última tarea se completa correctamente.
Prueba con una entrada inexistente entre los dos videos válidos, usando otro directorio de salida nuevo:
(
cd rust-watermark &&
./watermark-batch ./watermark.png ./mixed-output ./video1.mp4 ./missing.mp4 ./video2.mp4
)
Esto muestra Batch: 2 succeeded, 1 failed y termina con estado 1.
mixed-output/job-1.mp4 y mixed-output/job-3.mp4 se conservan; no hay un segundo archivo
de salida procesado correctamente. Si FFmpeg falla o informa de un error después de crear un
archivo parcial, el hilo de trabajo elimina ese archivo. Un error de eliminación se informa como
un fallo de la tarea y puede dejar el archivo parcial para que se elimine manualmente. Los fallos
de preparación, como una marca de agua inexistente o un directorio de salida que ya existe,
ocurren antes de que comience cualquier tarea y no eliminan los resultados anteriores.
Problemas comunes y soluciones
- FFmpeg no está disponible: las tareas afectadas no logran iniciarse. Comprueba el
PATHque hereda el ejecutable de Rust, no solo el de tu shell interactivo. - Falta un códec o un filtro: inspecciona el error en stderr y los codificadores y filtros instalados. Los diagnósticos de las ejecuciones simultáneas de FFmpeg pueden intercalarse; los mensajes de Rust identifican cada entrada.
- Una tarea se queda bloqueada: aquí no hay tiempo de espera máximo ni manejador de cancelación. En una ejecución normal que ha terminado, los procesos secundarios han finalizado y se ha esperado a que terminen los hilos de trabajo. Terminar por la fuerza Rust no garantiza la terminación de los procesos secundarios ni la eliminación de archivos parciales; un servicio necesita una política de supervisión independiente.
- Un archivo dañado aun así genera una salida: el ejecutor rechaza los errores informados, pero un decodificador puede ocultar daños sin informar de ningún error. Una tarea completada correctamente no demuestra que su fuente estuviera intacta. Inspecciona el video y el audio reales antes de confiar en el resultado.
- Los diagnósticos consumen memoria: este pequeño ejemplo captura en memoria la salida de errores de cada proceso secundario. No limita el tamaño de los registros. Un servicio necesita diagnósticos con límites de tamaño o transmitidos de forma continua, además de supervisión de procesos.
Usa esta herramienta como ejecutor local por lotes, no como servicio de subida. Para una cola más
grande, mide el uso de recursos antes de cambiar MAX_CHILDREN y decide cómo quienes
la invocan gestionarán los resultados correctos conservados y las tareas fallidas. La concurrencia
de Rust no implica por sí sola un procesamiento más rápido ni escalabilidad en producción.
