Automate video thumbnails with FFmpeg and .NET
To generate a video thumbnail from C#, seek to a timestamp, encode one frame, and check that an image was actually written. FFmpeg can finish without producing a frame when you seek past the video’s end. This console project uses FFMpegCore to create a JPEG and returns a nonzero exit code when it cannot create the thumbnail.
Set up FFmpeg and the .NET project
Use a Linux shell with the .NET 10 SDK
and both ffmpeg and ffprobe on PATH. Install FFmpeg through your distribution or the
FFmpeg download page. The example below creates an H.264 sample,
so your FFmpeg build also needs the libx264 encoder.
Check the installed tools:
dotnet --version && ffmpeg -version && ffprobe -version
From a directory where you keep projects, run this setup block. It creates a new directory and
leaves your shell inside it. If any command fails, stop and resolve that error before continuing;
the && operators prevent later setup commands from running after a failure.
mkdir ThumbnailDemo &&
cd ThumbnailDemo &&
dotnet new console --framework net10.0 --no-restore &&
dotnet add package FFMpegCore --version 5.2.0
This pins FFMpegCore 5.2.0, the wrapper version used here. The native FFmpeg executables are installed separately. You do not need a logging package or an image library for this example.
Create the thumbnail service
Save the following as ThumbnailService.cs inside ThumbnailDemo. It accepts a local video file,
a new .jpg destination, and a timestamp measured from the beginning of the video. The destination
directory must already exist.
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);
}
}
}
How the code works
The input Seek places -ss before -i. During transcoding, FFmpeg seeks to an earlier seek point
and decodes and discards frames up to the requested position, as described in its
seek documentation. The explicit -map 0:v:0 selects
the first video stream; an audio-only file cannot silently substitute an audio stream.
The output requests one MJPEG frame using a full-range JPEG pixel format. Scaling to 320:-2
calculates an even height from the source proportions, so the square-pixel sample below becomes
320 × 180 instead of being stretched to 320 × 240. The
scale filter documentation explains the negative
height value. Use ordinary SDR video with square pixels for this walkthrough; HDR tone mapping and
anamorphic-video handling need a separate filter choice.
The image2 update option treats the temporary
output as one filename. After FFmpeg finishes, the service checks the file’s size and probes its
codec and dimensions. Only then does it move the file to your destination.
Moving without overwrite
also refuses a destination created while FFmpeg was running. On ordinary completion or failure,
the finally block removes the temporary file; a forcibly terminated process can leave it behind.
Run the console program
Replace the generated Program.cs with this complete caller. It accepts seconds using a decimal
point, such as 0.25, and prints diagnostics to standard error. The return value becomes the
process exit code: zero for success, two for invalid arguments, and one for a failed operation.
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;
}
Still inside ThumbnailDemo, create a four-second test video: two seconds of red followed by two
seconds of blue. If sample.mp4 already exists, the initial check stops this block with a nonzero
status and preserves the file. Use a fresh project directory for a fresh sample.
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 the frame at three seconds:
dotnet run -- sample.mp4 thumbnail.jpg 3
Open thumbnail.jpg: it should be blue and measure 320 × 180 pixels. You can also inspect it with
ffprobe, then ask FFmpeg to decode it without writing another 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 -
The probe reports codec_name=mjpeg, width=320, and height=180. A successful decode is silent.
Replace sample.mp4 with your own local video path and choose a timestamp where a frame exists.
Quote paths containing spaces. Each invocation requires a new output name, including when you
repeat the same timestamp.
Handle missing frames and repeated runs
A short clip may end before the timestamp you requested. Try 0 for its first frame or a smaller
fractional timestamp. Seeking to or beyond the end can either make FFmpeg fail or leave no image;
both paths become a failed operation. The service does not silently substitute a different moment.
For the four-second sample, this command must return a nonzero status and leave no destination:
dotnet run -- sample.mp4 beyond-end.jpg 10
An unreadable video, a missing input, or an audio-only file also fails. If the tools cannot be
found, check PATH from the same shell that runs the program. If a destination already exists,
the program preserves its bytes and fails; choose another name to retry. This also protects the
input if you accidentally use it as the destination. Failed argument checks leave existing files
untouched.
For several previews, call GenerateAsync sequentially with different timestamps and output names.
Let an exception reach the caller so a batch cannot report complete success after skipping a failed
thumbnail. The single-frame example keeps scheduling and concurrency outside the service.
Check the runtime you deploy
This walkthrough was verified on Linux with .NET SDK 10.0.111, FFMpegCore 5.2.0, and FFmpeg/ffprobe 6.1.1 and 9.0.1. It does not establish compatibility with every intervening release, older .NET versions, Windows, or macOS. Keep both native executables available to the account running your program, and replay the sample and failure case when changing your deployment’s FFmpeg build.
If you need a hosted thumbnail workflow, see the video thumbnail Robot documentation. For this local project, keep the sample video as a quick check that a runtime update still creates the expected frame.
