Automatize miniaturas de vídeo com FFmpeg e .NET
Para gerar uma miniatura de vídeo a partir de C#, salte para um timestamp, codifique um quadro e verifique se uma imagem foi realmente gravada. O FFmpeg pode terminar sem produzir nenhum quadro quando você salta para além do fim do vídeo. Este projeto de console usa o FFMpegCore para criar um JPEG e retorna um código de saída diferente de zero quando não consegue criar a miniatura.
Configure o FFmpeg e o projeto .NET
Use um shell Linux com o SDK do .NET 10
e com ffmpeg e ffprobe disponíveis no PATH. Instale o FFmpeg pela sua distribuição ou pela
página de download do FFmpeg. O exemplo abaixo cria uma amostra em
H.264, então seu build do FFmpeg também precisa do codificador libx264.
Verifique as ferramentas instaladas:
dotnet --version && ffmpeg -version && ffprobe -version
A partir de um diretório onde você guarda seus projetos, execute este bloco de configuração. Ele
cria um novo diretório e deixa seu shell dentro dele. Se algum comando falhar, pare e resolva esse
erro antes de continuar; os operadores && impedem que os comandos de configuração seguintes sejam
executados após uma falha.
mkdir ThumbnailDemo &&
cd ThumbnailDemo &&
dotnet new console --framework net10.0 --no-restore &&
dotnet add package FFMpegCore --version 5.2.0
Isso fixa o FFMpegCore 5.2.0, a versão do wrapper usada aqui. Os executáveis nativos do FFmpeg são instalados separadamente. Você não precisa de um pacote de logging nem de uma biblioteca de imagens para este exemplo.
Crie o serviço de miniaturas
Salve o código a seguir como ThumbnailService.cs dentro de ThumbnailDemo. Ele aceita um arquivo de vídeo local,
um novo destino .jpg e um timestamp medido a partir do início do vídeo. O diretório de
destino já precisa existir.
using FFMpegCore;
public static class ThumbnailService
{
public static async Task GenerateAsync(
string inputFile, string outputFile, TimeSpan captureTime)
{
if (captureTime < TimeSpan.Zero)
throw new ArgumentException("The timestamp must not be negative.");
inputFile = Path.GetFullPath(inputFile);
outputFile = Path.GetFullPath(outputFile);
if (!File.Exists(inputFile))
throw new FileNotFoundException("The input video does not exist.", inputFile);
if (!Path.GetExtension(outputFile).Equals(".jpg", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Use a .jpg output filename.");
if (File.Exists(outputFile) || Directory.Exists(outputFile))
throw new IOException("The destination already exists. Choose a new filename.");
var directory = Path.GetDirectoryName(outputFile)!;
if (!Directory.Exists(directory))
throw new DirectoryNotFoundException("The output directory does not exist.");
var temporaryFile = Path.Combine(directory, $".thumbnail-{Guid.NewGuid():N}.jpg");
try
{
await FFMpegArguments
.FromFileInput(inputFile, addArguments: options => options.Seek(captureTime))
.OutputToFile(temporaryFile, false, options => options
.WithVideoCodec("mjpeg")
.WithFrameOutputCount(1)
.WithVideoFilters(filters => filters.Scale(320, -2))
.ForceFormat("image2")
.WithCustomArgument("-map 0:v:0 -pix_fmt yuvj420p -update 1"))
.ProcessAsynchronously();
if (!File.Exists(temporaryFile) || new FileInfo(temporaryFile).Length == 0)
throw new InvalidOperationException("No frame was produced. Try an earlier timestamp.");
var image = await FFProbe.AnalyseAsync(temporaryFile);
if (!image.VideoStreams.Any(stream =>
stream.CodecName == "mjpeg" && stream.Width == 320 && stream.Height > 0))
throw new InvalidOperationException("The output is not the expected JPEG thumbnail.");
File.Move(temporaryFile, outputFile, overwrite: false);
}
finally
{
File.Delete(temporaryFile);
}
}
}
Como o código funciona
O Seek de entrada coloca -ss antes de -i. Durante a transcodificação, o FFmpeg salta para um
ponto de posicionamento anterior e decodifica e descarta quadros até a posição solicitada, como
descrito na sua documentação de posicionamento. O -map 0:v:0 explícito
seleciona o primeiro stream de vídeo; assim, um arquivo somente de áudio não pode substituir
silenciosamente o vídeo por um stream de áudio.
A saída solicita um quadro MJPEG usando um formato de pixel JPEG de faixa completa. O
redimensionamento para 320:-2 calcula uma altura par a partir das proporções da origem, então a amostra
com pixels quadrados abaixo fica com 320 × 180 em vez de ser esticada para 320 × 240. A
documentação do filtro scale explica o valor negativo de
altura. Use um vídeo SDR comum com pixels quadrados neste passo a passo; o mapeamento de tons HDR e
o tratamento de vídeo anamórfico exigem outra escolha de filtro.
A opção update do image2 trata a saída
temporária como um único nome de arquivo. Depois que o FFmpeg termina, o serviço verifica o tamanho
do arquivo e analisa seu codec e suas dimensões. Só então ele move o arquivo para o seu destino.
A movimentação sem sobrescrita
também recusa um destino criado enquanto o FFmpeg estava em execução. Na conclusão normal ou em caso
de falha, o bloco finally remove o arquivo temporário; um processo encerrado à força pode deixá-lo para
trás.
Execute o programa de console
Substitua o Program.cs gerado por este código de chamada completo. Ele aceita segundos usando ponto
decimal, como 0.25, e imprime diagnósticos na saída de erro padrão. O valor de retorno se torna o
código de saída do processo: zero para sucesso, dois para argumentos inválidos e um para uma
operação com falha.
using System.Globalization;
if (args.Length != 3 ||
!double.TryParse(args[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) ||
!double.IsFinite(seconds) || seconds < 0)
{
Console.Error.WriteLine("Usage: dotnet run -- <input-video> <output.jpg> <seconds>");
return 2;
}
try
{
await ThumbnailService.GenerateAsync(args[0], args[1], TimeSpan.FromSeconds(seconds));
Console.WriteLine($"Created {Path.GetFullPath(args[1])}");
return 0;
}
catch (Exception error)
{
Console.Error.WriteLine($"Thumbnail failed: {error.Message}");
return 1;
}
Ainda dentro de ThumbnailDemo, crie um vídeo de teste de quatro segundos: dois segundos de vermelho seguidos
de dois segundos de azul. Se sample.mp4 já existir, a verificação inicial interrompe este bloco com um
status diferente de zero e preserva o arquivo. Use um diretório de projeto novo para gerar uma
amostra nova.
test ! -e sample.mp4 &&
ffmpeg -n -f lavfi -i "color=c=red:s=640x360:r=25:d=2" \
-f lavfi -i "color=c=blue:s=640x360:r=25:d=2" \
-filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v]" \
-map "[v]" -c:v libx264 -pix_fmt yuv420p sample.mp4
Capture o quadro em três segundos:
dotnet run -- sample.mp4 thumbnail.jpg 3
Abra thumbnail.jpg: a imagem deve ser azul e medir 320 × 180 pixels. Você também pode inspecioná-la
com ffprobe e depois pedir ao FFmpeg que a decodifique sem gravar outra imagem:
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height \
-of default=noprint_wrappers=1 thumbnail.jpg &&
ffmpeg -v error -xerror -i thumbnail.jpg -f null -
A análise informa codec_name=mjpeg, width=320 e height=180. Uma decodificação bem-sucedida não exibe nada.
Substitua sample.mp4 pelo caminho de um vídeo local seu e escolha um timestamp em que exista um quadro.
Coloque entre aspas os caminhos que contêm espaços. Cada execução exige um novo nome de saída,
inclusive quando você repete o mesmo timestamp.
Trate quadros ausentes e execuções repetidas
Um clipe curto pode terminar antes do timestamp solicitado. Tente 0 para obter o primeiro quadro
ou um timestamp fracionário menor. Saltar até o fim ou além dele pode fazer o FFmpeg falhar ou não
gerar nenhuma imagem; nos dois casos, o resultado é uma operação com falha. O serviço não substitui
silenciosamente o momento solicitado por outro.
Para a amostra de quatro segundos, este comando precisa retornar um status diferente de zero e não deixar nenhum destino:
dotnet run -- sample.mp4 beyond-end.jpg 10
Um vídeo ilegível, uma entrada ausente ou um arquivo somente de áudio também falham. Se as
ferramentas não forem encontradas, verifique o PATH no mesmo shell que executa o programa. Se um
destino já existir, o programa preserva seus bytes e falha; escolha outro nome para tentar de novo.
Isso também protege a entrada se você a usar acidentalmente como destino. Verificações de argumentos
que falham deixam os arquivos existentes intactos.
Para várias prévias, chame GenerateAsync sequencialmente com timestamps e nomes de saída diferentes.
Deixe que uma exceção chegue ao código chamador, para que um lote não possa relatar sucesso total
depois de pular uma miniatura com falha. O exemplo de quadro único mantém o agendamento e a
concorrência fora do serviço.
Verifique o ambiente de execução que você implanta
Este passo a passo foi verificado no Linux com o .NET SDK 10.0.111, o FFMpegCore 5.2.0 e o FFmpeg/ffprobe 6.1.1 e 9.0.1. Ele não comprova compatibilidade com todas as versões intermediárias, com versões mais antigas do .NET, com Windows nem com macOS. Mantenha os dois executáveis nativos disponíveis para a conta que executa o seu programa e execute novamente a amostra e o caso de falha ao trocar o build do FFmpeg da sua implantação.
Se você precisa de um fluxo de trabalho de miniaturas hospedado, consulte a documentação do Robot de miniaturas de vídeo. Para este projeto local, guarde o vídeo de amostra como uma verificação rápida de que uma atualização do ambiente de execução ainda gera o quadro esperado.
