Marca-d’água em vídeos de forma concorrente com Rust & FFmpeg
Use Rust para executar um pequeno lote de jobs de marca-d’água do FFmpeg, com no máximo dois processos filhos por vez. A ferramenta abaixo adiciona um PNG a cada vídeo, mantém a primeira faixa de áudio dele e retorna um status de saída diferente de zero se algum job falhar. Os vídeos processados com sucesso continuam disponíveis mesmo quando outro job falha.
Pré-requisitos
- Bash no Linux para os comandos abaixo.
- Rust 1.85.1 ou mais recente, com
rustcno seuPATH. - FFmpeg e ffprobe 6.1.1 ou mais recentes, com o encoder
libx264e os filtrosoverlay,coloresine. Os comandos que preparam os arquivos de teste também precisam dos encoders AAC e PNG.
O exemplo usa vídeos MP4 com um stream de vídeo sem rotação e de dimensões pares, com áudio AAC opcional, além de uma marca-d’água PNG de 8 bits pequena o suficiente para caber em cada quadro. Ele processa apenas o primeiro stream de vídeo e o primeiro stream de áudio, sem legendas nem faixas extras. O fluxo de trabalho no shell é específico para Linux; ele não é um guia de instalação testado para Windows ou macOS.
Limite o número de processos do FFmpeg
O Rust cuida do agendamento e dos status de saída; o FFmpeg cuida da decodificação, da composição e da codificação. Não há bindings do FFmpeg nem crates externos de Rust. Dois jobs rodam juntos, e ambos terminam antes de o próximo grupo começar. Esse agrupamento deliberadamente simples pode deixar uma vaga ociosa enquanto o job mais lento termina. Trata-se de um limite de concorrência, não de um benchmark de throughput.
Dois processos filhos não significam duas threads de CPU. O código solicita uma thread de codec para cada entrada e para o encoder de vídeo, e uma thread para o grafo de filtros complexo. O FFmpeg ainda pode criar outras threads internas. Essas configurações não impõem um limite total de CPU ou de memória; consulte a referência de opções do FFmpeg.
Configuração do ambiente
Instale uma toolchain estável e mantida do Rust por meio do rustup. No Ubuntu ou no Debian, instale o pacote FFmpeg da distribuição:
sudo apt-get update && sudo apt-get install -y ffmpeg
Verifique rustc --version, ffmpeg -version e ffprobe -version. Este exemplo foi testado com
Rust 1.85.1 e 1.98.1, e com FFmpeg 6.1.1 e 9.0.1. Inspecione ffmpeg -encoders e
ffmpeg -filters se faltar algum pré-requisito na sua build.
Crie um novo diretório a partir do seu diretório de trabalho atual. Se ele já existir, escolha outro nome em vez de excluí-lo:
mkdir rust-watermark
Salve o código-fonte completo abaixo como rust-watermark/watermark.rs. Compilamos este arquivo independente
com rustc, especificando explicitamente a edição e o caminho do executável. Nenhum projeto Cargo é criado,
portanto um workspace do Cargo que contenha o diretório, ou o diretório de destino configurado nele,
não controla esta build.
Como criar uma ferramenta básica de marca-d’água
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
}
}
}
O filtro overlay posiciona o canto superior esquerdo da marca-d’água
a 10 pixels das bordas superior e esquerda do vídeo. Repetir o último quadro do PNG o mantém visível
depois que essa entrada de um único quadro termina. O vídeo é recodificado em H.264 com yuv420p; o
mapeamento opcional 0:a:0? copia a primeira faixa de áudio sem recodificá-la. Caminhos de entrada
absolutos evitam que hifens iniciais sejam interpretados como opções, e -pattern_type none trata o nome do PNG
literalmente, incluindo caracteres %.
Crie dois vídeos de exemplo e uma marca-d’água
No diretório que contém rust-watermark, cole este bloco. Ele cria um vídeo azul de 2,32 segundos
com um tom de 440 Hz, um vídeo vermelho com um tom de 880 Hz e um PNG branco de 48 × 24 pixels. O
subshell mantém inalterados o seu diretório atual e as opções do shell, inclusive em caso de falha.
A verificação inicial rejeita nomes de arquivos de teste já existentes antes de gerar qualquer
arquivo. -n é uma verificação adicional contra sobrescrita, mas o FFmpeg pode relatar uma recusa
de sobrescrita com status 0, então ela não é a proteção da preparação. Use um diretório de projeto
novo para outra execução completa.
(
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
)
Compile e execute o lote
Ainda no diretório pai, compile o código-fonte salvo e execute exatamente esse executável:
(
cd rust-watermark &&
rustc --edition=2021 watermark.rs -o watermark-batch &&
./watermark-batch ./watermark.png ./output ./video1.mp4 ./video2.mp4
)
A cadeia && impede a execução de um executável antigo após uma compilação com falha. As saídas são
rust-watermark/output/job-1.mp4 e rust-watermark/output/job-2.mp4, numeradas na ordem de entrada.
O lote imprime Batch: 2 succeeded, 0 failed e termina com
status 0. Reproduza os dois arquivos: as dimensões, as cores de fundo e os tons devem continuar
distintos, com o retângulo branco em (10, 10) do início ao fim de cada vídeo.
O diretório de saída não pode existir previamente. Uma nova execução usando ./output falha antes de
iniciar qualquer processo filho do FFmpeg e deixa os arquivos anteriores inalterados. Use um novo
diretório de saída para outro lote e não deixe outro processo gravar nele. Os arquivos concluídos com
sucesso ficam visíveis enquanto o lote é executado; aguarde o status final antes de consumi-los. Este
não é um esquema de publicação atômica.
Tratamento de erros e logs
Command::output()
aguarda cada processo filho do FFmpeg e captura os diagnósticos dele. Um erro de inicialização, um
status de saída diferente de zero ou qualquer texto no stderr com -loglevel error faz o job falhar. A última
verificação é importante: o FFmpeg 6.1.1 pode relatar um erro de decodificador e ainda assim retornar
status 0, mesmo com -xerror.
O executor aguarda o término de todas as threads de worker de um grupo, registra os dois resultados e continua processando os grupos seguintes. A contagem acumulada de falhas determina o status final, mesmo quando o último job é bem-sucedido.
Teste uma entrada ausente entre os dois vídeos válidos, usando outro diretório de saída novo:
(
cd rust-watermark &&
./watermark-batch ./watermark.png ./mixed-output ./video1.mp4 ./missing.mp4 ./video2.mp4
)
Isso imprime Batch: 2 succeeded, 1 failed e termina com status 1.
mixed-output/job-1.mp4 e mixed-output/job-3.mp4 permanecem; não há uma segunda saída bem-sucedida.
Se o FFmpeg falhar ou relatar um erro depois de criar um arquivo parcial, o worker remove esse
arquivo. Um erro de remoção é relatado como falha do job e pode deixar o arquivo parcial para limpeza
manual. Falhas de preparação, como uma marca-d’água ausente ou um diretório de saída já existente,
acontecem antes de qualquer job começar e não removem resultados anteriores.
Problemas comuns e soluções
- O FFmpeg não está disponível: os jobs afetados não conseguem ser iniciados. Verifique o
PATHherdado pelo executável Rust, não apenas o do seu shell interativo. - Falta um codec ou filtro: inspecione o erro no stderr e os encoders e filtros instalados. Diagnósticos de processos FFmpeg concorrentes podem se intercalar; as mensagens do Rust identificam cada entrada.
- Um job trava: não há timeout nem tratamento de cancelamento aqui. Em uma execução comum concluída, os processos filhos já terminaram e o executor já aguardou o término de todas as threads de worker. Encerrar o processo Rust não garante o término dos processos filhos nem a limpeza dos arquivos parciais; um serviço precisa de uma política de supervisão separada.
- Um arquivo danificado ainda gera saída: o executor rejeita erros relatados, mas um decodificador pode ocultar danos sem relatar nenhum erro. Um job bem-sucedido não prova que a origem estava íntegra. Inspecione o vídeo e o áudio reais antes de confiar no resultado.
- Os diagnósticos consomem memória: este pequeno exemplo captura na memória a saída de erro de cada processo filho. Ele não limita o tamanho dos logs. Um serviço precisa de diagnósticos limitados ou transmitidos em stream, além de supervisão de processos.
Mantenha isto como um executor de lotes local, não como um serviço de upload. Para uma fila maior,
meça o uso de recursos antes de alterar MAX_CHILDREN e decida como os chamadores lidam com os sucessos
mantidos e com os jobs que falharam. A concorrência do Rust, por si só, não garante processamento
mais rápido nem escalabilidade em produção.
