diff --git a/FileFlowsPlugins.sln b/FileFlowsPlugins.sln index 4d126a06..ff9eeb7b 100644 --- a/FileFlowsPlugins.sln +++ b/FileFlowsPlugins.sln @@ -27,6 +27,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby", "Emby\Emby.csproj", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Apprise", "Apprise\Apprise.csproj", "{CA750701-C4CF-482F-B5F3-A40E188F3E14}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageNodes", "ImageNodes\ImageNodes.csproj", "{3C6B9933-B6BC-4C00-9247-71F575AA276B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +83,10 @@ Global {CA750701-C4CF-482F-B5F3-A40E188F3E14}.Debug|Any CPU.Build.0 = Debug|Any CPU {CA750701-C4CF-482F-B5F3-A40E188F3E14}.Release|Any CPU.ActiveCfg = Release|Any CPU {CA750701-C4CF-482F-B5F3-A40E188F3E14}.Release|Any CPU.Build.0 = Release|Any CPU + {3C6B9933-B6BC-4C00-9247-71F575AA276B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C6B9933-B6BC-4C00-9247-71F575AA276B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C6B9933-B6BC-4C00-9247-71F575AA276B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C6B9933-B6BC-4C00-9247-71F575AA276B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ImageNodes/ExtensionMethods.cs b/ImageNodes/ExtensionMethods.cs new file mode 100644 index 00000000..3fcad0a3 --- /dev/null +++ b/ImageNodes/ExtensionMethods.cs @@ -0,0 +1,6 @@ +namespace FileFlows.ImageNodes; + +internal static class ExtensionMethods +{ + public static string? EmptyAsNull(this string str) => str == string.Empty ? null : str; +} diff --git a/ImageNodes/GlobalUsings.cs b/ImageNodes/GlobalUsings.cs new file mode 100644 index 00000000..134f7dce --- /dev/null +++ b/ImageNodes/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Text; +global using System.ComponentModel.DataAnnotations; +global using FileFlows.Plugin; +global using FileFlows.Plugin.Attributes; +global using SixLabors.ImageSharp; +global using static FileFlows.ImageNodes.Images.Constants; \ No newline at end of file diff --git a/ImageNodes/ImageNodes.csproj b/ImageNodes/ImageNodes.csproj new file mode 100644 index 00000000..a4d1ca61 Binary files /dev/null and b/ImageNodes/ImageNodes.csproj differ diff --git a/ImageNodes/ImageNodes.en.json b/ImageNodes/ImageNodes.en.json new file mode 100644 index 00000000..2da1fcd4 --- /dev/null +++ b/ImageNodes/ImageNodes.en.json @@ -0,0 +1,56 @@ +{ + "Flow": { + "Parts": { + "ImageFlip": { + "Outputs": { + "1": "Image flipped, saved to new temporary file" + }, + "Description": "Flips an image", + "Fields": { + "Format": "Format", + "Format-Help": "The image format to convert to", + "Vertical": "Vertical", + "Vertical-Help": "If set the image will be flipped vertically, otherwise horizontally" + } + }, + "ImageFormat": { + "Outputs": { + "1": "Image converted, saved to new temporary file" + }, + "Description": "Converts an image to the specified format", + "Fields": { + "Format": "Format", + "Format-Help": "The image format to convert to" + } + }, + "ImageResizer": { + "Outputs": { + "1": "Image resized, saved to new temporary file" + }, + "Description": "Resizes an image", + "Fields": { + "Format": "Format", + "Format-Help": "The image format to convert to", + "Width": "Width", + "Width-Suffix": "px", + "Height": "Height", + "Height-Suffix": "px", + "Mode": "Mode", + "Mode-Help": "The mode to use when resizing the image" + } + }, + "ImageRotate": { + "Outputs": { + "1": "Image rotated, saved to new temporary file" + }, + "Description": "Rotates an image", + "Fields": { + "Format": "Format", + "Format-Help": "The image format to convert to", + "Angle": "Angle", + "Angle-Help": "The rotation angle" + } + } + } + } +} \ No newline at end of file diff --git a/ImageNodes/Images/Constants.cs b/ImageNodes/Images/Constants.cs new file mode 100644 index 00000000..62067847 --- /dev/null +++ b/ImageNodes/Images/Constants.cs @@ -0,0 +1,13 @@ +namespace FileFlows.ImageNodes.Images; + +public class Constants +{ + internal const string IMAGE_FORMAT_BMP = "Bmp"; + internal const string IMAGE_FORMAT_GIF = "Gif"; + internal const string IMAGE_FORMAT_JPEG = "Jpeg"; + internal const string IMAGE_FORMAT_PBM = "Pbm"; + internal const string IMAGE_FORMAT_PNG = "Png"; + internal const string IMAGE_FORMAT_TIFF = "Tiff"; + internal const string IMAGE_FORMAT_TGA = "Tga"; + internal const string IMAGE_FORMAT_WEBP = "WebP"; +} \ No newline at end of file diff --git a/ImageNodes/Images/Enums.cs b/ImageNodes/Images/Enums.cs new file mode 100644 index 00000000..dc6c536e --- /dev/null +++ b/ImageNodes/Images/Enums.cs @@ -0,0 +1,9 @@ +namespace FileFlows.ImageNodes.Images; + +public enum ResizeMode +{ + Fill = 1, + Contain = 2, + Cover = 3, + None = 4 +} \ No newline at end of file diff --git a/ImageNodes/Images/ImageFlip.cs b/ImageNodes/Images/ImageFlip.cs new file mode 100644 index 00000000..9602c20c --- /dev/null +++ b/ImageNodes/Images/ImageFlip.cs @@ -0,0 +1,27 @@ +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Processing; + +namespace FileFlows.ImageNodes.Images; + +public class ImageFlip: ImageNode +{ + public override int Inputs => 1; + public override int Outputs => 1; + public override FlowElementType Type => FlowElementType.Process; + public override string Icon => "fas fa-sync-alt"; + + [Boolean(2)] + public bool Vertical { get; set; } + + + public override int Execute(NodeParameters args) + { + using var image = Image.Load(args.WorkingFile, out IImageFormat format); + image.Mutate(c => c.Flip(Vertical ? FlipMode.Vertical : FlipMode.Horizontal)); + var formatOpts = GetFormat(args); + SaveImage(image, formatOpts.file, formatOpts.format ?? format); + args.SetWorkingFile(formatOpts.file); + + return 1; + } +} diff --git a/ImageNodes/Images/ImageFormat.cs b/ImageNodes/Images/ImageFormat.cs new file mode 100644 index 00000000..d4584c82 --- /dev/null +++ b/ImageNodes/Images/ImageFormat.cs @@ -0,0 +1,22 @@ +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Processing; + +namespace FileFlows.ImageNodes.Images; + +public class ImageFormat: ImageNode +{ + public override int Inputs => 1; + public override int Outputs => 1; + public override FlowElementType Type => FlowElementType.Process; + public override string Icon => "fas fa-file-image"; + + public override int Execute(NodeParameters args) + { + using var image = Image.Load(args.WorkingFile, out IImageFormat format); + + var formatOpts = GetFormat(args); + SaveImage(image, formatOpts.file, formatOpts.format ?? format); + args.SetWorkingFile(formatOpts.file); + return 1; + } +} diff --git a/ImageNodes/Images/ImageNode.cs b/ImageNodes/Images/ImageNode.cs new file mode 100644 index 00000000..8fb280c8 --- /dev/null +++ b/ImageNodes/Images/ImageNode.cs @@ -0,0 +1,93 @@ +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Pbm; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Tga; +using SixLabors.ImageSharp.Formats.Tiff; +using SixLabors.ImageSharp.Formats.Webp; + +namespace FileFlows.ImageNodes.Images; + +public abstract class ImageNode : Node +{ + [Select(nameof(FormatOptions), 1)] + public string Format { get; set; } + + private static List _FormatOptions; + public static List FormatOptions + { + get + { + if (_FormatOptions == null) + { + _FormatOptions = new List + { + new ListOption { Value = IMAGE_FORMAT_BMP, Label = "Bitmap"}, + new ListOption { Value = IMAGE_FORMAT_GIF, Label = "GIF"}, + new ListOption { Value = IMAGE_FORMAT_JPEG, Label = "JPEG"}, + new ListOption { Value = IMAGE_FORMAT_PBM, Label = "PBM"}, + new ListOption { Value = IMAGE_FORMAT_PNG, Label = "PNG"}, + new ListOption { Value = IMAGE_FORMAT_TIFF, Label = "TIFF"}, + new ListOption { Value = IMAGE_FORMAT_TGA, Label = "TGA" }, + new ListOption { Value = IMAGE_FORMAT_WEBP, Label = "WebP"}, + }; + } + return _FormatOptions; + } + } + + protected (IImageFormat? format, string file) GetFormat(NodeParameters args) + { + IImageFormat? format = null; + + var newFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString()); + switch (this.Format) + { + case IMAGE_FORMAT_BMP: + newFile = newFile + ".bmp"; + format = BmpFormat.Instance; + break; + case IMAGE_FORMAT_GIF: + newFile = newFile + ".gif"; + format = GifFormat.Instance; + break; + case IMAGE_FORMAT_JPEG: + newFile = newFile + ".jpg"; + format = JpegFormat.Instance; + break; + case IMAGE_FORMAT_PBM: + newFile = newFile + ".pbm"; + format = PbmFormat.Instance; + break; + case IMAGE_FORMAT_PNG: + newFile = newFile + ".png"; + format = PngFormat.Instance; + break; + case IMAGE_FORMAT_TIFF: + newFile = newFile + ".tiff"; + format = TiffFormat.Instance; + break; + case IMAGE_FORMAT_TGA: + newFile = newFile + ".tga"; + format = TgaFormat.Instance; + break; + case IMAGE_FORMAT_WEBP: + newFile = newFile + ".webp"; + format = WebpFormat.Instance; + break; + default: + newFile = newFile + "." + args.WorkingFile.Substring(args.WorkingFile.LastIndexOf(".") + 1); + break; + } + + return (format, newFile); + } + + protected void SaveImage(Image img, string file, IImageFormat format) + { + using Stream outStream = new FileStream(file, FileMode.Create); + img.Save(outStream, format); + } +} \ No newline at end of file diff --git a/ImageNodes/Images/ImageResizer.cs b/ImageNodes/Images/ImageResizer.cs new file mode 100644 index 00000000..10dd7226 --- /dev/null +++ b/ImageNodes/Images/ImageResizer.cs @@ -0,0 +1,79 @@ +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Pbm; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Tga; +using SixLabors.ImageSharp.Formats.Tiff; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Processing; + +namespace FileFlows.ImageNodes.Images; + +public class ImageResizer: ImageNode +{ + public override int Inputs => 1; + public override int Outputs => 2; + public override FlowElementType Type => FlowElementType.Process; + public override string Icon => "fas fa-image"; + + + [Select(nameof(ResizeModes), 2)] + public ResizeMode Mode { get; set; } + + private static List _ResizeModes; + public static List ResizeModes + { + get + { + if (_ResizeModes == null) + { + _ResizeModes = new List + { + new ListOption { Value = Images.ResizeMode.Fill, Label = "Fill (Stretches to fit)"}, + new ListOption { Value = Images.ResizeMode.Contain, Label = "Contain (Preserves aspect ratio but contained in bounds)"}, + new ListOption { Value = Images.ResizeMode.Cover, Label = "Cover (Preserves aspect ratio)"}, + new ListOption { Value = Images.ResizeMode.None, Label = "None (Not resized)"} + }; + } + return _ResizeModes; + } + } + + [NumberInt(3)] + [Range(0, int.MaxValue)] + public int Width { get; set; } + [NumberInt(4)] + [Range(0, int.MaxValue)] + public int Height { get; set; } + + public override int Execute(NodeParameters args) + { + using var image = Image.Load(args.WorkingFile, out IImageFormat format); + SixLabors.ImageSharp.Processing.ResizeMode rzMode; + switch (Mode) + { + case ResizeMode.Contain: rzMode = SixLabors.ImageSharp.Processing.ResizeMode.Pad; + break; + case ResizeMode.Cover: rzMode = SixLabors.ImageSharp.Processing.ResizeMode.Crop; + break; + case ResizeMode.Fill: rzMode = SixLabors.ImageSharp.Processing.ResizeMode.Stretch; + break; + default: rzMode = SixLabors.ImageSharp.Processing.ResizeMode.BoxPad; + break; + } + + var formatOpts = GetFormat(args); + + image.Mutate(c => c.Resize(new ResizeOptions() + { + Size = new Size(Width, Height), + Mode = rzMode + })); + + SaveImage(image, formatOpts.file, formatOpts.format ?? format); + args.SetWorkingFile(formatOpts.file); + return 1; + } +} diff --git a/ImageNodes/Images/ImageRotate.cs b/ImageNodes/Images/ImageRotate.cs new file mode 100644 index 00000000..821ec0e1 --- /dev/null +++ b/ImageNodes/Images/ImageRotate.cs @@ -0,0 +1,44 @@ +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Processing; + +namespace FileFlows.ImageNodes.Images; + +public class ImageRotate: ImageNode +{ + public override int Inputs => 1; + public override int Outputs => 1; + public override FlowElementType Type => FlowElementType.Process; + public override string Icon => "fas fa-undo"; + + [Select(nameof(AngleOptions), 2)] + public int Angle { get; set; } + + private static List _AngleOptions; + public static List AngleOptions + { + get + { + if (_AngleOptions == null) + { + _AngleOptions = new List + { + new ListOption { Value = 90, Label = "90"}, + new ListOption { Value = 180, Label = "180"}, + new ListOption { Value = 270, Label = "270"} + }; + } + return _AngleOptions; + } + } + + public override int Execute(NodeParameters args) + { + using var image = Image.Load(args.WorkingFile, out IImageFormat format); + image.Mutate(c => c.Rotate(Angle)); + var formatOpts = GetFormat(args); + SaveImage(image, formatOpts.file, formatOpts.format ?? format); + args.SetWorkingFile(formatOpts.file); + + return 1; + } +} diff --git a/ImageNodes/Plugin.cs b/ImageNodes/Plugin.cs new file mode 100644 index 00000000..b3c740e3 --- /dev/null +++ b/ImageNodes/Plugin.cs @@ -0,0 +1,11 @@ +namespace FileFlows.ImageNodes; + +public class Plugin : FileFlows.Plugin.IPlugin +{ + public string Name => "Image Nodes"; + public string MinimumVersion => "0.5.2.690"; + + public void Init() + { + } +} diff --git a/ImageNodes/Tests/ImageTests.cs b/ImageNodes/Tests/ImageTests.cs new file mode 100644 index 00000000..7f3b3b1a --- /dev/null +++ b/ImageNodes/Tests/ImageTests.cs @@ -0,0 +1,67 @@ +#if(DEBUG) + +using FileFlows.ImageNodes.Images; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FileFlows.ImageNodes.Tests; + +[TestClass] +public class ImageNodesTests +{ + [TestMethod] + public void ImageNodes_Basic_ImageFormat() + { + var args = new NodeParameters("/home/john/Pictures/fileflows.png", new TestLogger(), false, string.Empty) + { + TempPath = "/home/john/src/temp/" + }; + + var node = new ImageFormat(); + node.Format = IMAGE_FORMAT_GIF; + Assert.AreEqual(1, node.Execute(args)); + } + + [TestMethod] + public void ImageNodes_Basic_Resize() + { + var args = new NodeParameters("/home/john/Pictures/fileflows.png", new TestLogger(), false, string.Empty) + { + TempPath = "/home/john/src/temp/" + }; + + var node = new ImageResizer(); + node.Width = 1000; + node.Height = 500; + node.Mode = ResizeMode.Fill; + Assert.AreEqual(1, node.Execute(args)); + } + + [TestMethod] + public void ImageNodes_Basic_Flip() + { + var args = new NodeParameters("/home/john/Pictures/36410427.png", new TestLogger(), false, string.Empty) + { + TempPath = "/home/john/src/temp/" + }; + + var node = new ImageFlip(); + node.Vertical = false; + Assert.AreEqual(1, node.Execute(args)); + } + + + [TestMethod] + public void ImageNodes_Basic_Rotate() + { + var args = new NodeParameters("/home/john/Pictures/36410427.png", new TestLogger(), false, string.Empty) + { + TempPath = "/home/john/src/temp/" + }; + + var node = new ImageRotate(); + node.Angle = 270; + Assert.AreEqual(1, node.Execute(args)); + } +} + +#endif \ No newline at end of file diff --git a/ImageNodes/Tests/TestLogger.cs b/ImageNodes/Tests/TestLogger.cs new file mode 100644 index 00000000..1283aa00 --- /dev/null +++ b/ImageNodes/Tests/TestLogger.cs @@ -0,0 +1,59 @@ +#if(DEBUG) + +namespace FileFlows.ImageNodes.Tests; + +using FileFlows.Plugin; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +internal class TestLogger : ILogger +{ + private List Messages = new List(); + + public void DLog(params object[] args) => Log("DBUG", args); + + public void ELog(params object[] args) => Log("ERRR", args); + + public void ILog(params object[] args) => Log("INFO", args); + + public void WLog(params object[] args) => Log("WARN", args); + private void Log(string type, object[] args) + { + if (args == null || args.Length == 0) + return; + string message = type + " -> " + + string.Join(", ", args.Select(x => + x == null ? "null" : + x.GetType().IsPrimitive || x is string ? x.ToString() : + System.Text.Json.JsonSerializer.Serialize(x))); + Messages.Add(message); + } + + public bool Contains(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return false; + + string log = string.Join(Environment.NewLine, Messages); + return log.Contains(message); + } + + public override string ToString() + { + return String.Join(Environment.NewLine, this.Messages.ToArray()); + } + + public string GetTail(int length = 50) + { + if (length <= 0) + length = 50; + if (Messages.Count <= length) + return string.Join(Environment.NewLine, Messages); + return string.Join(Environment.NewLine, Messages.TakeLast(length)); + } +} + +#endif \ No newline at end of file diff --git a/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs b/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs index d60d9efa..7254e3da 100644 --- a/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs +++ b/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs @@ -1,44 +1,44 @@ -#if(DEBUG) - -using FileFlows.VideoNodes.FfmpegBuilderNodes; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using VideoNodes.Tests; - -namespace FileFlows.VideoNodes.Tests.FfmpegBuilderTests -{ - [TestClass] - public class FfmpegBuilder_MetadataTests - { - [TestMethod] - public void FfmpegBuilder_MetadataJson() - { - const string file = @"D:\videos\unprocessed\basic.mkv"; - var logger = new TestLogger(); - const string ffmpeg = @"C:\utils\ffmpeg\ffmpeg.exe"; - var vi = new VideoInfoHelper(ffmpeg, logger); - var vii = vi.Read(file); - var args = new NodeParameters(file, logger, false, string.Empty); - VideoMetadata md = System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(@"D:\videos\metadata.json")); - args.Variables.Add("VideoMetadata", md); - args.GetToolPathActual = (string tool) => ffmpeg; - args.TempPath = @"D:\videos\temp"; - args.Parameters.Add("VideoInfo", vii); - - - FfmpegBuilderStart ffStart = new (); - Assert.AreEqual(1, ffStart.Execute(args)); - - - FfmpegBuilderVideoMetadata ffMetadata = new(); - Assert.AreEqual(1, ffMetadata.Execute(args)); - - FfmpegBuilderExecutor ffExecutor = new(); - int result = ffExecutor.Execute(args); - - string log = logger.ToString(); - Assert.AreEqual(1, result); - } - } -} - -#endif \ No newline at end of file +// #if(DEBUG) +// +// using FileFlows.VideoNodes.FfmpegBuilderNodes; +// using Microsoft.VisualStudio.TestTools.UnitTesting; +// using VideoNodes.Tests; +// +// namespace FileFlows.VideoNodes.Tests.FfmpegBuilderTests +// { +// [TestClass] +// public class FfmpegBuilder_MetadataTests +// { +// [TestMethod] +// public void FfmpegBuilder_MetadataJson() +// { +// const string file = @"D:\videos\unprocessed\basic.mkv"; +// var logger = new TestLogger(); +// const string ffmpeg = @"C:\utils\ffmpeg\ffmpeg.exe"; +// var vi = new VideoInfoHelper(ffmpeg, logger); +// var vii = vi.Read(file); +// var args = new NodeParameters(file, logger, false, string.Empty); +// VideoMetadata md = System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(@"D:\videos\metadata.json")); +// args.Variables.Add("VideoMetadata", md); +// args.GetToolPathActual = (string tool) => ffmpeg; +// args.TempPath = @"D:\videos\temp"; +// args.Parameters.Add("VideoInfo", vii); +// +// +// FfmpegBuilderStart ffStart = new (); +// Assert.AreEqual(1, ffStart.Execute(args)); +// +// +// FfmpegBuilderVideoMetadata ffMetadata = new(); +// Assert.AreEqual(1, ffMetadata.Execute(args)); +// +// FfmpegBuilderExecutor ffExecutor = new(); +// int result = ffExecutor.Execute(args); +// +// string log = logger.ToString(); +// Assert.AreEqual(1, result); +// } +// } +// } +// +// #endif \ No newline at end of file