Automatiser les miniatures vidéo avec FFmpeg et .NET
Pour générer une miniature vidéo depuis C#, positionnez-vous sur un horodatage, encodez une image et vérifiez qu’une image a bien été écrite. FFmpeg peut se terminer sans produire d’image lorsque vous vous positionnez au-delà de la fin de la vidéo. Ce projet console utilise FFMpegCore pour créer un JPEG et renvoie un code de sortie non nul lorsqu’il ne parvient pas à créer la miniature.
Configurer FFmpeg et le projet .NET
Utilisez un shell Linux avec le SDK .NET 10
ainsi que ffmpeg et ffprobe accessibles dans PATH. Installez FFmpeg via votre distribution ou la
page de téléchargement de FFmpeg. L’exemple ci-dessous crée un échantillon H.264 ;
votre build de FFmpeg doit donc aussi inclure l’encodeur libx264.
Vérifiez les outils installés :
dotnet --version && ffmpeg -version && ffprobe -version
Depuis un répertoire où vous conservez vos projets, exécutez ce bloc de configuration. Il crée un
nouveau répertoire et laisse votre shell à l’intérieur. Si une commande échoue, arrêtez-vous et
résolvez cette erreur avant de continuer ; les opérateurs && empêchent l’exécution des
commandes de configuration suivantes après un échec.
mkdir ThumbnailDemo &&
cd ThumbnailDemo &&
dotnet new console --framework net10.0 --no-restore &&
dotnet add package FFMpegCore --version 5.2.0
Cela épingle FFMpegCore 5.2.0, la version du wrapper utilisée ici. Les exécutables natifs de FFmpeg sont installés séparément. Cet exemple ne nécessite ni paquet de journalisation ni bibliothèque d’images.
Créer le service de miniatures
Enregistrez le code suivant sous ThumbnailService.cs dans ThumbnailDemo. Il accepte un fichier vidéo local,
une nouvelle destination .jpg et un horodatage mesuré depuis le début de la vidéo. Le répertoire
de destination doit déjà exister.
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);
}
}
}
Fonctionnement du code
Côté entrée, Seek place -ss avant -i. Lors du transcodage, FFmpeg se positionne sur un point de
positionnement antérieur, puis décode et ignore les images jusqu’à la position demandée, comme
l’explique sa documentation sur le positionnement. La sélection explicite -map 0:v:0
retient le premier flux vidéo ; avec un fichier audio seul, un flux audio ne peut pas être
substitué silencieusement.
La sortie demande une seule image MJPEG avec un format de pixel JPEG en plage complète. La mise à
l’échelle vers 320:-2 calcule une hauteur paire à partir des proportions de la source ;
l’échantillon à pixels carrés ci-dessous devient donc 320 × 180 au lieu d’être étiré en
320 × 240. La
documentation du filtre scale explique la valeur de hauteur
négative. Pour ce tutoriel, utilisez une vidéo SDR ordinaire à pixels carrés ; le mappage de tons
HDR et la gestion des vidéos anamorphiques nécessitent un autre choix de filtre.
L’option update d’image2 traite la sortie
temporaire comme un seul nom de fichier. Une fois FFmpeg terminé, le service vérifie la taille du
fichier, puis analyse son codec et ses dimensions. Ce n’est qu’ensuite qu’il déplace le fichier vers
votre destination.
Le déplacement sans écrasement
refuse également une destination créée pendant l’exécution de FFmpeg. En cas de fin normale ou
d’échec, le bloc finally supprime le fichier temporaire ; un processus terminé de force peut le laisser en place.
Exécuter le programme console
Remplacez le Program.cs généré par cet appelant complet. Il accepte des secondes écrites avec un point
décimal, par exemple 0.25, et affiche les diagnostics sur la sortie d’erreur standard. La valeur de
retour devient le code de sortie du processus : zéro en cas de succès, deux pour des arguments
invalides et un pour une opération échouée.
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;
}
Toujours dans ThumbnailDemo, créez une vidéo de test de quatre secondes : deux secondes de rouge suivies de
deux secondes de bleu. Si sample.mp4 existe déjà, la vérification initiale arrête ce bloc avec un
statut non nul et préserve le fichier. Utilisez un nouveau répertoire de projet pour obtenir un
nouvel échantillon.
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
Capturez l’image à trois secondes :
dotnet run -- sample.mp4 thumbnail.jpg 3
Ouvrez thumbnail.jpg : l’image doit être bleue et mesurer 320 × 180 pixels. Vous pouvez aussi
l’inspecter avec ffprobe, puis demander à FFmpeg de la décoder sans écrire d’autre image :
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 -
L’analyse indique codec_name=mjpeg, width=320 et height=180. Un décodage réussi ne produit aucune sortie.
Remplacez sample.mp4 par le chemin de votre propre vidéo locale et choisissez un horodatage où une image
existe. Mettez entre guillemets les chemins contenant des espaces. Chaque appel nécessite un nouveau
nom de sortie, y compris lorsque vous répétez le même horodatage.
Gérer les images manquantes et les exécutions répétées
Un clip court peut se terminer avant l’horodatage demandé. Essayez 0 pour sa première image ou
un horodatage fractionnaire plus petit. Se positionner à la fin ou au-delà peut soit faire échouer
FFmpeg, soit ne produire aucune image ; dans les deux cas, l’opération est considérée comme
échouée. Le service ne substitue pas silencieusement un autre moment.
Pour l’échantillon de quatre secondes, cette commande doit renvoyer un statut non nul et ne laisser aucune destination :
dotnet run -- sample.mp4 beyond-end.jpg 10
Une vidéo illisible, une entrée manquante ou un fichier audio seul échouent également. Si les outils
sont introuvables, vérifiez PATH depuis le shell qui exécute le programme. Si une destination existe
déjà, le programme préserve ses octets et échoue ; choisissez un autre nom pour réessayer. Cela
protège aussi l’entrée si vous l’utilisez par erreur comme destination. Les vérifications
d’arguments en échec laissent les fichiers existants intacts.
Pour plusieurs aperçus, appelez GenerateAsync de manière séquentielle avec des horodatages et des noms de
sortie différents. Laissez une exception remonter jusqu’à l’appelant afin qu’un lot ne puisse pas
signaler un succès complet après avoir ignoré une miniature en échec. L’exemple à image unique
laisse la planification et la concurrence en dehors du service.
Vérifier l’environnement d’exécution déployé
Ce tutoriel a été vérifié sous Linux avec le SDK .NET 10.0.111, FFMpegCore 5.2.0 et FFmpeg/ffprobe 6.1.1 et 9.0.1. Il n’établit pas la compatibilité avec toutes les versions intermédiaires, avec les versions plus anciennes de .NET, ni avec Windows ou macOS. Gardez les deux exécutables natifs accessibles au compte qui exécute votre programme, et relancez l’échantillon et le cas d’échec lorsque vous changez le build FFmpeg de votre déploiement.
Si vous avez besoin d’un flux de travail hébergé pour les miniatures, consultez la documentation du Robot de miniatures vidéo (English). Pour ce projet local, conservez la vidéo d’échantillon comme vérification rapide qu’une mise à jour de l’environnement d’exécution crée toujours l’image attendue.
