diff --git a/FileFlows.Plugin.dll b/FileFlows.Plugin.dll
index fa673466..8113a193 100644
Binary files a/FileFlows.Plugin.dll and b/FileFlows.Plugin.dll differ
diff --git a/FileFlows.Plugin.pdb b/FileFlows.Plugin.pdb
index 06a4020e..b5a7bbf8 100644
Binary files a/FileFlows.Plugin.pdb and b/FileFlows.Plugin.pdb differ
diff --git a/VideoNodes/Tests/CreateThumbnailTests.cs b/VideoNodes/Tests/CreateThumbnailTests.cs
new file mode 100644
index 00000000..80db8031
--- /dev/null
+++ b/VideoNodes/Tests/CreateThumbnailTests.cs
@@ -0,0 +1,51 @@
+#if(DEBUG)
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using FileFlows.VideoNodes;
+using Moq;
+
+namespace VideoNodes.Tests;
+
+///
+/// Thumbnail tests
+///
+[TestClass]
+public class CreateThumbnailTests : VideoTestBase
+{
+ private Mock _imageHelper;
+ private int ImageDarkness = 50;
+
+ ///
+ protected override void TestStarting()
+ {
+ base.TestStarting();
+ _imageHelper = new Mock();
+ _imageHelper.Setup(x => x.CalculateImageDarkness(It.IsAny())).Returns(() => ImageDarkness);
+ }
+
+ ///
+ /// Tests creating a basic thumbnail
+ ///
+ [TestMethod]
+ public void BasicThumbnail()
+ {
+ var args = GetVideoNodeParameters(VideoMkv);
+ args.ImageHelper = _imageHelper.Object;
+
+ VideoFile vf = new();
+ vf.PreExecute(args);
+ vf.Execute(args);
+
+ CreateThumbnail element = new();
+ element.PreExecute(args);
+ element.Width = 320;
+ element.Height = 240;
+ element.SkipBlackFrames = false;
+ int output = element.Execute(args);
+
+ Assert.AreEqual(1, output);
+ }
+
+}
+
+#endif
\ No newline at end of file
diff --git a/VideoNodes/VideoNodes/CreateThumbnail.cs b/VideoNodes/VideoNodes/CreateThumbnail.cs
new file mode 100644
index 00000000..6911f4a3
--- /dev/null
+++ b/VideoNodes/VideoNodes/CreateThumbnail.cs
@@ -0,0 +1,240 @@
+using System.IO;
+
+namespace FileFlows.VideoNodes;
+
+///
+/// Node that creates a video thumbnail with resizing options and aspect ratio considerations.
+///
+public class CreateThumbnail : VideoNode
+{
+ ///
+ /// Gets or sets the destination path for zipping.
+ ///
+ [TextVariable(1)]
+ public string Destination { get; set; } = string.Empty;
+
+ ///
+ /// The width of the thumbnail.
+ ///
+ [NumberInt(2)]
+ public int Width { get; set; } = 1280;
+
+ ///
+ /// The height of the thumbnail.
+ ///
+ [NumberInt(3)]
+ public int Height { get; set; } = 720;
+
+ ///
+ /// The time in the video to capture the thumbnail (in seconds or percentage of the video duration).
+ ///
+ [Time(4)]
+ public TimeSpan Time { get; set; }
+
+
+ ///
+ /// The image resize mode to use.
+ ///
+ [Select(nameof(ResizeModeOptions), 5)]
+ public ResizeMode ResizeMode { get; set; } = ResizeMode.Contain;
+
+ ///
+ /// Indicates whether to detect black frames or credits and skip them.
+ ///
+ [Boolean(6)]
+ public bool SkipBlackFrames { get; set; } = true;
+
+ private static List _ResizeModeOptions;
+ public static List ResizeModeOptions
+ {
+ get
+ {
+ if (_ResizeModeOptions == null)
+ {
+ _ResizeModeOptions = new List
+ {
+ new () { Label = "Fill", Value = ResizeMode.Fill },
+ new () { Label = "Contain", Value = ResizeMode.Contain },
+ new () { Label = "Cover", Value = ResizeMode.Cover },
+ new () { Label = "Pad", Value = ResizeMode.Pad },
+ new () { Label = "Min", Value = ResizeMode.Min },
+ new () { Label = "Max", Value = ResizeMode.Max }
+ };
+ }
+ return _ResizeModeOptions;
+ }
+ }
+
+ ///
+ /// Executes the node, creating a thumbnail for the video.
+ ///
+ /// The node parameters.
+ /// The result code: 1 for success, 2 for failure.
+ public override int Execute(NodeParameters args)
+ {
+ try
+ {
+ VideoInfo videoInfo = GetVideoInfo(args);
+ if (videoInfo == null)
+ {
+ args.FailureReason = "No Video Information found.";
+ args.Logger?.ELog(args.FailureReason);
+ return -1;
+ }
+ var lfResult = args.FileService.GetLocalPath(args.WorkingFile);
+ if (lfResult.Failed(out var error))
+ {
+ args.FailureReason = "Failed to get local file: " + error;
+ args.Logger?.ELog(args.FailureReason);
+ return -1;
+ }
+ string localFile = lfResult.Value;
+
+ // Ensure time is within bounds
+ TimeSpan captureTime = GetValidCaptureTime(videoInfo.VideoStreams[0].Duration);
+
+ // Generate a thumbnail
+ string thumbnailPath = Path.Combine(args.TempPath, Guid.NewGuid() + ".png");
+ if (CaptureThumbnail(args, localFile, captureTime, thumbnailPath) == false)
+ {
+ args.Logger?.WLog("Failed to generate a thumbnail");
+ return 2;
+ }
+
+ // Check for black frames or credits and skip if necessary
+ if (SkipBlackFrames && IsBlackOrCredits(thumbnailPath, args))
+ {
+ captureTime = AdjustCaptureTime(captureTime, videoInfo.VideoStreams[0].Duration);
+ if (CaptureThumbnail(args, localFile, captureTime, thumbnailPath) == false)
+ {
+ args.Logger?.WLog("Failed to generate a thumbnail 2");
+ return 2;
+ }
+ }
+
+ // Resize the thumbnail
+ string resizedThumbnailPath = Path.Combine(args.TempPath, Guid.NewGuid() + ".png");
+ var resizeResult = args.ImageHelper.Resize(thumbnailPath, resizedThumbnailPath, Width, Height, ResizeMode);
+ if (resizeResult.Failed(out error))
+ {
+ args.Logger?.ELog("Failed to resize thumbnail: " + error);
+ return 2;
+ }
+
+
+ string dest = args.ReplaceVariables(Destination, stripMissing: true);
+ if (string.IsNullOrWhiteSpace(dest))
+ {
+ dest = resizedThumbnailPath;
+ }
+ else
+ {
+ if (args.FileService.FileMove(resizedThumbnailPath, dest).Failed(out error))
+ {
+ args.Logger?.WLog("Failed to move file: " + error);
+ return 2;
+ }
+ }
+ args.Logger?.ILog("Thumbnail Path: " + dest);
+ // Set output variable
+ args.UpdateVariables(new Dictionary { { "ThumbnailPath", dest } });
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ args.FailureReason = "Failed to create thumbnail: " + ex.Message;
+ args.Logger?.ELog(args.FailureReason);
+ return -1;
+ }
+ }
+
+ ///
+ /// Gets a valid capture time ensuring it's within the bounds of the video duration.
+ ///
+ /// The video duration.
+ /// The valid capture time as a TimeSpan.
+ private TimeSpan GetValidCaptureTime(TimeSpan duration)
+ {
+ // If Time is set to a value less than 1 second, treat it as a percentage (e.g., 0.1 means 10% of the video)
+ if (Time.TotalSeconds < 1)
+ return TimeSpan.FromTicks((long)(duration.Ticks * Time.TotalSeconds));
+
+ // Otherwise, treat it as an absolute value in seconds
+ return Time < duration ? Time : duration; // Cap Time to the video duration
+ }
+
+ ///
+ /// Captures a thumbnail using ffmpeg.
+ ///
+ /// The node parameters.
+ /// The local file path of the video.
+ /// The time to capture the thumbnail in the video.
+ /// The output file path for the thumbnail.
+ /// True if successful, otherwise false.
+ private bool CaptureThumbnail(NodeParameters args, string localFile, TimeSpan time, string outputPath)
+ {
+ var result = args.Process.ExecuteShellCommand(new ExecuteArgs
+ {
+ Command = FFMPEG,
+ ArgumentList = [
+ "-ss", ((int)time.TotalSeconds).ToString(), // seek to the time
+ "-i", localFile, // input file
+ "-frames:v", "1", // capture just one frame
+ "-update", "1", // allow overwriting the file
+ outputPath // output single image, no sequence pattern
+ ]
+ }).Result;
+
+ if (result.ExitCode != 0 || File.Exists(outputPath) == false)
+ {
+ args.Logger?.ELog("FFmpeg failed to capture thumbnail.");
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Checks if the captured thumbnail is mostly black or contains credits.
+ ///
+ /// The path to the thumbnail image.
+ /// The node parameters.
+ /// True if the image is mostly black or contains credits, otherwise false.
+ private bool IsBlackOrCredits(string thumbnailPath, NodeParameters args)
+ {
+ // Example logic for checking if an image is mostly black or very small (e.g., credits)
+ try
+ {
+ var result = args.ImageHelper.CalculateImageDarkness(thumbnailPath);
+ if (result.Failed(out var error))
+ {
+ args.Logger?.WLog("Falied to calculate darkness: " + error);
+ return false;
+ }
+
+ args.Logger?.ILog($"Darkness level detected: {result.Value}");
+ return result.Value < 20;
+ }
+ catch (Exception ex)
+ {
+ args.Logger?.WLog($"Error analyzing thumbnail {thumbnailPath}: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Adjusts the capture time if the thumbnail is black or contains credits.
+ ///
+ /// The current time of the thumbnail.
+ /// The total duration of the video.
+ /// The adjusted capture time.
+ private TimeSpan AdjustCaptureTime(TimeSpan currentTime, TimeSpan duration)
+ {
+ // Move the capture time by 10% of the video length forwards or backwards
+ TimeSpan shift = TimeSpan.FromTicks((long)(duration.Ticks * 0.1));
+ if (currentTime + shift < duration)
+ return currentTime + shift;
+
+ return currentTime > shift ? currentTime - shift : TimeSpan.Zero; // Shift backwards if near the end
+ }
+}