Skip to content

Commit

Permalink
Merge pull request #133 from pre-martin/feature/flags-with-custom-images
Browse files Browse the repository at this point in the history
Flags with custom images
  • Loading branch information
pre-martin authored Sep 13, 2024
2 parents 4dea3dc + da3a5e8 commit 839d9a5
Show file tree
Hide file tree
Showing 43 changed files with 1,144 additions and 166 deletions.
258 changes: 175 additions & 83 deletions StreamDeckSimHub.Plugin/Actions/FlagsAction.cs

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions StreamDeckSimHub.Plugin/Actions/FlagsSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace StreamDeckSimHub.Plugin.Actions;

/// <summary>
/// Settings for Flags Action, which are set in the Stream Deck UI.
/// </summary>
public class FlagsSettings
{
public string NoFlag { get; set; } = "flags/flag-none.svg";
public string BlackFlag { get; set; } = "flags/flag-black.svg";
public string BlueFlag { get; set; } = "flags/flag-blue.svg";
public string CheckeredFlag { get; set; } = "flags/flag-checkered.svg";
public string GreenFlag { get; set; } = "flags/flag-green.svg";
public string OrangeFlag { get; set; } = "flags/flag-orange.svg";
public string WhiteFlag { get; set; } = "flags/flag-white.svg";
public string YellowFlag { get; set; } = "flags/flag-yellow.svg";
public string YellowFlagSec1 { get; set; } = "flags/flag-yellow-s1.png";
public string YellowFlagSec2 { get; set; } = "flags/flag-yellow-s2.png";
public string YellowFlagSec3 { get; set; } = "flags/flag-yellow-s3.png";

public override string ToString()
{
return
$"No: {NoFlag}, Black: {BlackFlag}, Blue: {BlueFlag}, Checkered: {CheckeredFlag}, Green: {GreenFlag}, Orange: {OrangeFlag}, White: {WhiteFlag}, Yellow: {YellowFlag}, YellowFlagSec1: {YellowFlagSec1}, YellowFlagSec2: {YellowFlagSec2}, YellowFlagSec3: {YellowFlagSec3}";
}
}
5 changes: 4 additions & 1 deletion StreamDeckSimHub.Plugin/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (C) 2023 Martin Renner
// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)

using System.IO.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -35,4 +36,6 @@ void ConfigureServices(IServiceCollection serviceCollection)
serviceCollection.AddSingleton<ShakeItStructureFetcher>();
serviceCollection.AddSingleton<PropertyComparer>();
serviceCollection.AddSingleton<ImageUtils>();
}
serviceCollection.AddSingleton<ImageManager>();
serviceCollection.AddSingleton<IFileSystem>(new FileSystem());
}
67 changes: 51 additions & 16 deletions StreamDeckSimHub.Plugin/StreamDeckSimHub.Plugin.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<PackageReference Include="NLog" Version="5.3.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.10" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4" />
<PackageReference Include="Svg.Skia" Version="2.0.0.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" />
<Reference Include="SharpDeck">
<HintPath>..\lib\SharpDeck.dll</HintPath>
</Reference>
Expand Down Expand Up @@ -83,6 +85,12 @@
<None Update="pi\js\dial.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="pi\flags.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="pi\js\flags.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\actions\dial.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down Expand Up @@ -146,25 +154,10 @@
<None Update="images\icons\dial.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-black.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-blue.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-checkered.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-green.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-orange.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-white.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\flag-yellow.svg">
<None Update="images\icons\undefined.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\icons\input.png">
Expand Down Expand Up @@ -215,6 +208,48 @@
<None Update="pi\react\react.production.min.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-black.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-blue.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-checkered.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-green.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-none.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-orange.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-white.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-yellow.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-yellow-s1.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-yellow-s2.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\flag-yellow-s3.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\[email protected]">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\[email protected]">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="images\custom\flags\[email protected]">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup Label="streamdeck-javascript-sdk">
Expand Down
85 changes: 85 additions & 0 deletions StreamDeckSimHub.Plugin/Tools/ImageManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (C) 2024 Martin Renner
// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)

using System.IO.Abstractions;
using System.Text.RegularExpressions;
using NLog;
using SixLabors.ImageSharp;

namespace StreamDeckSimHub.Plugin.Tools;

public class ImageManager(IFileSystem fileSystem, ImageUtils imageUtils)
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private static readonly string[] SupportedExtensions = [".svg", ".png", ".jpg", ".jpeg", ".gif"];
private readonly IDirectoryInfo _customImagesDirectory = fileSystem.DirectoryInfo.New(Path.Combine("images", "custom"));

/// <summary>
/// Returns an array with all custom images. The images are returned with their relative path inside
/// the "custom images" directory. Images with quality suffix in their name ("@2x") are excluded from the list.
/// </summary>
public string[] ListCustomImages()
{
var imageQualityRegex = new Regex(@"@\dx\."); // "[email protected]", "[email protected]", ...
try
{
var fn = _customImagesDirectory.FullName;
return _customImagesDirectory.GetFiles("*.*", SearchOption.AllDirectories)
.Where(fileInfo => SupportedExtensions.Contains(fileInfo.Extension.ToLowerInvariant()))
.Select(fileInfo => fileInfo.FullName[(fn.Length + 1)..])
.Select(fileName => imageQualityRegex.Replace(fileName, "."))
.Select(fileName => fileName.Replace(@"\", "/"))
.ToList()
.Distinct()
.OrderBy(fileName => fileName.Count(c => c == '/') + fileName)
.ToArray();
}
catch (Exception e)
{
Logger.Error($"Could not load custom images: {e.Message}");
return [];
}
}

/// <summary>
/// Returns an image from the "custom image" folder.
/// </summary>
/// <param name="relativePath">The relative path inside the "custom images" folder</param>
/// <param name="sdKeyInfo">Information about the specific Stream Deck key or dial</param>
/// <returns>The image in encoded form, or a default error image, if it could not be loaded.</returns>
public Image GetCustomImage(string relativePath, StreamDeckKeyInfo sdKeyInfo)
{
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
if (Path.GetExtension(relativePath).Equals(".svg", StringComparison.InvariantCultureIgnoreCase))
{
// No quality suffix for SVG files.
var fn = Path.Combine(_customImagesDirectory.FullName, relativePath);
return imageUtils.FromSvgFile(fn, sdKeyInfo);
}

var newName = FindResolutionForKeyInfo(_customImagesDirectory.FullName, relativePath, sdKeyInfo);
var newFn = Path.Combine(_customImagesDirectory.FullName, newName);
try
{
using var stream = fileSystem.File.OpenRead(newFn);
return Image.Load(stream);
}
catch (Exception e)
{
Logger.Warn($"Could not load custom image '{newFn}': {e.Message}");
return imageUtils.GetErrorImage(sdKeyInfo);
}
}

private string FindResolutionForKeyInfo(string baseDir, string relativePath, StreamDeckKeyInfo sdKeyInfo)
{
if (sdKeyInfo.IsHighRes)
{
var extension = Path.GetExtension(relativePath);
var hqFile = relativePath[..relativePath.LastIndexOf('.')] + "@2x" + extension;
if (fileSystem.File.Exists(Path.Combine(baseDir, hqFile))) return hqFile;
}

return relativePath;
}
}
107 changes: 81 additions & 26 deletions StreamDeckSimHub.Plugin/Tools/ImageUtils.cs
Original file line number Diff line number Diff line change
@@ -1,55 +1,110 @@
// Copyright (C) 2023 Martin Renner
// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)

using System.Text.RegularExpressions;
using NLog;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SkiaSharp;
using Svg.Skia;

namespace StreamDeckSimHub.Plugin.Tools;

public class ImageUtils
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly Regex _lineBreakRegex = new("[\r\n]+");

/// <summary>
/// Loads a given SVG file from the file system and removes all line breaks. This is important so that
/// Stream Deck can handle the SVG image.
/// </summary>
public string LoadSvg(string svgFile)
private readonly Image _errorSmall;
private readonly Image _errorLarge;
private readonly Image _empty;

public ImageUtils()
{
try
{
var svgAsText = File.ReadAllText(svgFile);
return EncodeSvg(_lineBreakRegex.Replace(svgAsText, ""));
}
catch (Exception e)
{
Logger.Warn($"Could not find file '{svgFile}' : {e.Message}");
return EncodeSvg("<svg viewBox=\"0 0 70 70\"><rect x=\"20\" y=\"20\" width=\"30\" height=\"30\" stroke=\"red\" fill=\"red\" /></svg>");
}
_errorSmall = CreateErrorImage(72, 72);
_errorLarge = CreateErrorImage(144, 144);

_empty = new Image<Rgba64>(144, 144);
}

private Image<Rgba32> CreateErrorImage(int width, int height)
{
var image = new Image<Rgba32>(width, height);
var thickness = (float)width / 10;
image.Mutate(x => x
.DrawLine(Color.Red, thickness, new PointF(0, 0), new Point(width, height))
.DrawLine(Color.Red, thickness, new PointF(0, height), new Point(width, 0)));

return image;
}

public Image GetErrorImage(StreamDeckKeyInfo keyInfo)
{
return keyInfo.IsHighRes ? _errorLarge : _errorSmall;
}

/// <summary>
/// Prepends the mime type to a given SVG image. Stream Deck expects it like that.
/// Returns a static image without content. Can be used to initialize images before they are actually loaded.
/// </summary>
public string EncodeSvg(string svg)
/// <remarks>Do not modify the returned image, because this will affect all instances.</remarks>
/// <returns>Always a high-res image. Stream Deck can scale it for us, because there will be no scaling losses.</returns>
public Image GetEmptyImage()
{
return "data:image/svg+xml;charset=utf8," + svg;
return _empty;
}

/// <summary>
/// Converts the given Image into the PNG format and prepends the mime type. Stream Deck expects it like that.
/// Loads an SVG file and coverts it into a JPEG file. Internally, we only handle <c>Image</c> instances, that's the
/// reason why we convert vector to bitmap data.
/// </summary>
private string EncodePng(Image image)
/// <param name="svgFileName">The path to the image</param>
/// <param name="sdKeyInfo">The vector data is scaled for the given <c>StreamDeckKeyInfo</c></param>
/// <returns>A bitmap image. If the SVG cannot be loaded, a static error image is returned.</returns>
public virtual Image FromSvgFile(string svgFileName, StreamDeckKeyInfo sdKeyInfo)
{
using var stream = new MemoryStream();
image.SaveAsPng(stream);
return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray());
try
{
using var svg = SKSvg.CreateFromFile(svgFileName);
var keyWidth = sdKeyInfo.KeySize.width;
var keyHeight = sdKeyInfo.KeySize.height;

if (svg.Model is null)
{
Logger.Warn($"Could not determine model of SVG file '{svgFileName}'. The file seems to be invalid.");
return GetErrorImage(sdKeyInfo);
}

if (svg.Picture is null)
{
Logger.Warn($"Could not determine picture of SVG file '{svgFileName}'. The file seems to be invalid.");
return GetErrorImage(sdKeyInfo);
}


var scaleX = keyWidth / svg.Model.CullRect.Width;
var scaleY = keyHeight / svg.Model.CullRect.Height;

// Stream Deck always scales to a square. So no need to keep the aspect ratio here - just scale to square.
//var scale = scaleX > scaleY ? scaleY : scaleX;

var bitmap = svg.Picture.ToBitmap(SKColor.Empty, scaleX, scaleY, SKColorType.Rgba8888, SKAlphaType.Premul,
SKColorSpace.CreateSrgb());
if (bitmap is null)
{
// We have a valid SVG file, but no bitmap. This means that the SVG file is empty (empty "svg" element).
return _empty;
}

var data = bitmap.Encode(SKEncodedImageFormat.Png, 90);
return Image.Load(data.ToArray());
}
catch (Exception e)
{
Logger.Warn(e, $"Could not read SVG file '{svgFileName}' and convert it into a bitmap");
return sdKeyInfo.IsHighRes ? _errorLarge : _errorSmall;
}
}

/// <summary>
Expand All @@ -66,6 +121,6 @@ public string GenerateDialImage(string title)
};
image.Mutate(x => x.DrawText(textOptions, title, Color.White));

return EncodePng(image);
return image.ToBase64String(PngFormat.Instance);
}
}
Loading

0 comments on commit 839d9a5

Please sign in to comment.