From ed2b0356a4e92e375cb6cb58c2130a0c6b6b50db Mon Sep 17 00:00:00 2001 From: John Andrews Date: Fri, 6 May 2022 01:17:40 +1200 Subject: [PATCH] added Image Nodes --- FileFlowsPlugins.sln | 6 ++ ImageNodes/ExtensionMethods.cs | 6 ++ ImageNodes/GlobalUsings.cs | 7 ++ ImageNodes/ImageNodes.csproj | Bin 0 -> 3128 bytes ImageNodes/ImageNodes.en.json | 56 +++++++++++ ImageNodes/Images/Constants.cs | 13 +++ ImageNodes/Images/Enums.cs | 9 ++ ImageNodes/Images/ImageFlip.cs | 27 +++++ ImageNodes/Images/ImageFormat.cs | 22 +++++ ImageNodes/Images/ImageNode.cs | 93 ++++++++++++++++++ ImageNodes/Images/ImageResizer.cs | 79 +++++++++++++++ ImageNodes/Images/ImageRotate.cs | 44 +++++++++ ImageNodes/Plugin.cs | 11 +++ ImageNodes/Tests/ImageTests.cs | 67 +++++++++++++ ImageNodes/Tests/TestLogger.cs | 59 +++++++++++ .../FfmpegBuilder_MetadataTests.cs | 88 ++++++++--------- 16 files changed, 543 insertions(+), 44 deletions(-) create mode 100644 ImageNodes/ExtensionMethods.cs create mode 100644 ImageNodes/GlobalUsings.cs create mode 100644 ImageNodes/ImageNodes.csproj create mode 100644 ImageNodes/ImageNodes.en.json create mode 100644 ImageNodes/Images/Constants.cs create mode 100644 ImageNodes/Images/Enums.cs create mode 100644 ImageNodes/Images/ImageFlip.cs create mode 100644 ImageNodes/Images/ImageFormat.cs create mode 100644 ImageNodes/Images/ImageNode.cs create mode 100644 ImageNodes/Images/ImageResizer.cs create mode 100644 ImageNodes/Images/ImageRotate.cs create mode 100644 ImageNodes/Plugin.cs create mode 100644 ImageNodes/Tests/ImageTests.cs create mode 100644 ImageNodes/Tests/TestLogger.cs 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 0000000000000000000000000000000000000000..a4d1ca61babf1083823a3a549b2774fb360c7162 GIT binary patch literal 3128 zcmb`JZ%-3J5XR>%CVqztF#!_23Pv;rN~(xrq&84|Wnv12w$gv;T?M|p`g?X+j%{f{ zG|gSN-PvcJnSFLh z?AUTUwP#qZW3y#t+pro41N(vfF|&ysu;R>F@3HdO&1P7fGMie}-dmk-Z?y=*V|(PD zgtY-}=y>IJX&>DBu??>*W#ujF)?luMWo|o+8pL#B=Xe_7zYJ&1th;zNtc2J3_To{X z?}9cYM~a{W&Y{ba@{ob8+%)Wn`4c#oY`%4jT(vRly4UNc(WWh8gneQhz^Qr_a|Y7} zJWpV@16#4$;XNQ58Sy`{W!od;Z}F_z=ER-_oYeMNsi-~LveUt$JtZ%T_MK;ikK&!j zAm$qQduvK z>+Dv@Yeqypr-2S5sUj!BF=J)up%^if8hBD!2mCe)9x>`Uoi``^&9{tE#ZBsNpyJD0 zH4`GZX%%BsV3xy4r%kbcV$=pNu?p`d|LQDeE*F!wF;cuUWp|bu?p#;FY`WZv&!pWX ziZpvhOsWXpHq@j;*mU98hH*~ZnY{t8L`Cvv<&@cQR#fqN%z2M@W5z7D9b47B4t{dp zs;g!6qgs@aolE{1^DXr#Psy$ECT@?VF&~ds=B!Nk?bnX3YPWdK@*21m*_&Ng3)@{h zN!hb2ul7aNHF5Q#$1n8}br03S$-PTmq$wRjU7+u3N{O5l9#ubY_gUD?vM4IHRNZ$t zdMmH$UtO{-4c(tz!7RpBMX2NBi&Os4y|>w6DP46)CqbvFg3oiZa7nz%e5eHXSsUW@ z9nP;UVk(#kcUDG(v4H34`NVON>N@Z(dVXR{u1EL`>QuCdP8~H^tzeOm!%f~xPH#t8 zv>1mDzTkY;SW}!rb(2#bWk`D=#Z2$Yyjwz@H5n|5`$95N@BN+S6&C6!{^ks(F`dMnPBc1 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