Automatiza miniaturas de video con FFmpeg y .NET
Para generar una miniatura de video desde C#, busca una marca de tiempo, codifica un fotograma y comprueba que realmente se haya escrito una imagen. FFmpeg puede terminar sin producir un fotograma si buscas más allá del final del video. Este proyecto de consola usa FFMpegCore para crear un JPEG y devuelve un código de salida distinto de cero cuando no puede crear la miniatura.
Configura FFmpeg y el proyecto .NET
Usa un shell de Linux con el SDK de .NET 10
y tanto ffmpeg como ffprobe en PATH.
Instala FFmpeg mediante tu distribución o la
página de descarga de FFmpeg. El ejemplo siguiente crea una muestra H.264,
así que tu compilación de FFmpeg también necesita el codificador libx264.
Comprueba las herramientas instaladas:
dotnet --version && ffmpeg -version && ffprobe -version
Desde un directorio donde guardes proyectos, ejecuta este bloque de configuración. Crea un directorio
nuevo y deja tu shell dentro de él. Si algún comando falla, detente y resuelve ese error antes de
continuar; los operadores && impiden que se ejecuten los comandos de
configuración posteriores a un fallo.
mkdir ThumbnailDemo &&
cd ThumbnailDemo &&
dotnet new console --framework net10.0 --no-restore &&
dotnet add package FFMpegCore --version 5.2.0
Esto fija FFMpegCore 5.2.0, la versión de la biblioteca envolvente usada aquí. Los ejecutables nativos de FFmpeg se instalan por separado. No necesitas un paquete de registro de eventos ni una biblioteca de imágenes para este ejemplo.
Crea el servicio de miniaturas
Guarda lo siguiente como ThumbnailService.cs dentro de ThumbnailDemo.
Acepta un archivo de video local, un nuevo destino .jpg y una marca de tiempo
medida desde el inicio del video. El directorio de destino ya debe 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);
}
}
}
Cómo funciona el código
La opción de entrada Seek coloca -ss antes de -i.
Durante la transcodificación, FFmpeg se sitúa en un punto de búsqueda anterior y decodifica y descarta
fotogramas hasta la posición solicitada, como se describe en su
documentación de búsqueda temporal. La opción explícita
-map 0:v:0 selecciona el primer flujo de video; un archivo que solo contiene audio
no puede sustituirlo silenciosamente por un flujo de audio.
La salida solicita un fotograma MJPEG con un formato de píxeles JPEG de rango completo. El escalado a
320:-2 calcula una altura par a partir de las proporciones de origen, por lo
que la muestra con píxeles cuadrados que aparece más abajo queda en 320 × 180 en lugar de estirarse a
320 × 240. La documentación del filtro scale explica el valor negativo
de la altura. Usa video SDR convencional con píxeles cuadrados para este tutorial; el mapeo de tonos
HDR y el manejo de video anamórfico requieren elegir filtros por separado.
La opción update de image2 trata la salida
temporal como un único nombre de archivo. Cuando FFmpeg termina, el servicio comprueba el tamaño del
archivo e inspecciona su códec y sus dimensiones. Solo entonces mueve el archivo a tu destino.
La operación de mover sin sobrescribir
también rechaza un destino creado mientras FFmpeg se estaba ejecutando. Cuando el proceso termina
normalmente o falla, el bloque finally elimina el archivo temporal; si el
proceso se termina a la fuerza, el archivo puede permanecer.
Ejecuta el programa de consola
Reemplaza el archivo generado Program.cs por este código completo que invoca el
servicio. Acepta segundos con punto decimal, como 0.25, e imprime diagnósticos
en la salida de error estándar. El valor devuelto se convierte en el código de salida del proceso:
cero para éxito, dos para argumentos no válidos y uno para una operación fallida.
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;
}
Sin salir de ThumbnailDemo, crea un video de prueba de cuatro segundos: dos segundos
de rojo seguidos de dos segundos de azul. Si sample.mp4 ya existe, la comprobación
inicial detiene este bloque con un estado distinto de cero y conserva el archivo. Usa un directorio
de proyecto nuevo para crear una muestra nueva.
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
Captura el fotograma a los tres segundos:
dotnet run -- sample.mp4 thumbnail.jpg 3
Abre thumbnail.jpg: debería ser azul y medir 320 × 180 píxeles. También puedes
inspeccionarlo con ffprobe y luego pedir a FFmpeg que lo decodifique sin escribir
otra imagen:
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 -
La inspección indica codec_name=mjpeg, width=320 y height=180.
Una decodificación correcta no produce mensajes.
Reemplaza sample.mp4 por la ruta de tu propio video local y elige una marca de
tiempo donde exista un fotograma. Pon entre comillas las rutas que contengan espacios. Cada
invocación requiere un nombre de salida nuevo, incluso cuando repitas la misma marca de tiempo.
Maneja los fotogramas ausentes y las ejecuciones repetidas
Un clip corto puede terminar antes de la marca de tiempo que solicitaste. Prueba
0 para obtener su primer fotograma o una marca de tiempo fraccionaria
menor. Buscar el final o una posición posterior puede hacer que FFmpeg falle o no genere ninguna
imagen; ambos casos se consideran una operación fallida. El servicio no sustituye silenciosamente
el momento solicitado por otro.
Para la muestra de cuatro segundos, este comando debe devolver un estado distinto de cero y no crear ningún archivo de destino:
dotnet run -- sample.mp4 beyond-end.jpg 10
Un video que no se puede leer, una entrada ausente o un archivo que solo contiene audio también
provocan un fallo. Si no se encuentran las herramientas, comprueba PATH
desde el mismo shell que ejecuta el programa. Si un destino ya existe, el programa conserva sus
bytes y falla; elige otro nombre para volver a intentarlo. Esto también protege la entrada si la
usas accidentalmente como destino. Las comprobaciones de argumentos fallidas dejan intactos los
archivos existentes.
Para obtener varias vistas previas, llama a GenerateAsync secuencialmente con marcas
de tiempo y nombres de salida diferentes. Deja que las excepciones lleguen al código que hace la
llamada para que un lote no pueda informar de un éxito completo tras omitir una miniatura fallida.
El ejemplo de un solo fotograma mantiene la planificación y la concurrencia fuera del servicio.
Comprueba el entorno de ejecución que despliegas
Este tutorial se verificó en Linux con el SDK de .NET 10.0.111, FFMpegCore 5.2.0 y FFmpeg/ffprobe 6.1.1 y 9.0.1. No establece compatibilidad con todas las versiones intermedias, versiones anteriores de .NET, Windows ni macOS. Mantén ambos ejecutables nativos disponibles para la cuenta que ejecuta tu programa y vuelve a ejecutar la muestra y el caso de fallo cuando cambies la compilación de FFmpeg de tu despliegue.
Si necesitas un flujo de trabajo de miniaturas alojado, consulta la documentación del Robot de miniaturas de video. Para este proyecto local, conserva el video de muestra como comprobación rápida de que una actualización del entorno de ejecución sigue creando el fotograma esperado.
