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 + } +}