From 7611bacc73c7e04d94e7e41d13ab839a8fe24caf Mon Sep 17 00:00:00 2001 From: hexawyz <8518235+hexawyz@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:24:26 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Improve=20support=20for=20StreamDec?= =?UTF-8?q?k=20monitors.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important to note that because there is currently no way to map buttons to actions, this is somewaht useless, but gotta start somewhere. Also, will need to implement automatic rotation transforms for embedded monitors 😅 --- .../StreamDeckDeviceDriver.cs | 5 +- .../EmbeddedMonitorService.cs | 1 + .../Exo.Service.Core/ImageStorageService.cs | 100 ++++++++++++++++-- src/Exo/Ui/Exo.Settings.Ui/ChangedProperty.cs | 1 + .../EmbeddedMonitorDeviceSettingsControl.xaml | 55 +++++++--- .../EmbeddedMonitorImageSettingsControl.xaml | 8 +- ...mbeddedMonitorImageSettingsControl.xaml.cs | 1 + .../EmbeddedMonitorSettingControl.xaml | 6 +- .../EmbeddedMonitorFeaturesViewModel.cs | 15 ++- 9 files changed, 159 insertions(+), 33 deletions(-) diff --git a/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs b/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs index 1f3eba74..cc36bb52 100644 --- a/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs +++ b/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs @@ -267,11 +267,12 @@ public Button(StreamDeckDeviceDriver driver, byte buttonId) Guid IEmbeddedMonitor.MonitorId => _driver._buttonIds[_keyIndex]; - EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Square, _driver.ButtonImageSize, PixelFormat.B8G8R8, ImageFormats.Bitmap | ImageFormats.Jpeg, false); + // Bitmap seems to not work at all. Until I find a way to understand how colors are mapped, it is better to disable it. (e.g. black would give dark purple, white would give maroon) + EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Square, _driver.ButtonImageSize, PixelFormat.B8G8R8, /*ImageFormats.Bitmap | */ImageFormats.Jpeg, false); ValueTask IEmbeddedMonitor.SetImageAsync(UInt128 imageId, ImageFormat imageFormat, ReadOnlyMemory data, CancellationToken cancellationToken) { - if (imageFormat is not ImageFormat.Bitmap or ImageFormat.Jpeg) + if (imageFormat is not (ImageFormat.Bitmap or ImageFormat.Jpeg)) { return ValueTask.FromException(ExceptionDispatchInfo.SetCurrentStackTrace(new ArgumentOutOfRangeException(nameof(imageFormat)))); } diff --git a/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs b/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs index ac523bf6..dc82ffff 100644 --- a/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs +++ b/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs @@ -290,6 +290,7 @@ private async Task SetImageAsyncCore(ImageStorageService imageStorageService, UI new(region.Left, region.Top, region.Width, region.Height), _imageFormats, (_capabilities & EmbeddedMonitorCapabilities.AnimatedImages) != 0 ? _imageFormats & ImageFormats.Gif : 0, + _pixelFormat, new(_width, _height), _shape == MonitorShape.Circle ); diff --git a/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs b/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs index 21b73f5d..dc82cc6a 100644 --- a/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs +++ b/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs @@ -11,6 +11,8 @@ using Exo.Images; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Exo.Service; @@ -329,6 +331,7 @@ public ImageFile GetImageFile(UInt128 imageId) Rectangle sourceRectangle, ImageFormats targetStaticFormats, ImageFormats targetAnimatedFormats, + PixelFormat targetPixelFormat, Size targetSize, bool shouldApplyCircularMask ) @@ -351,9 +354,11 @@ bool shouldApplyCircularMask // First and foremost, adjust the animation stripping requirement based on the image and the supported formats of the device. bool shouldStripAnimations = metadata.IsAnimated && targetAnimatedFormats == 0; + bool targetIsStatic = shouldStripAnimations || !metadata.IsAnimated; + // Then, determine which set of formats should be used based on the image. // If the image supports at least one animated format, we should be able to convert to that format. - var applicableFormats = shouldStripAnimations ? targetStaticFormats : targetAnimatedFormats; + var applicableFormats = targetIsStatic ? targetStaticFormats : targetAnimatedFormats; // Then, Determine the target image format based on what is allowed. ImageFormat targetFormat; @@ -364,7 +369,7 @@ bool shouldApplyCircularMask targetFormat = metadata.Format; } // If an animated image needs to be converted to a different format, we will restrict ourselves to a specific subset of formats supporting animations. (GIF and WebP) - else if (!shouldStripAnimations) + else if (!targetIsStatic) { // By order of preference: WebP (lossless), PNG, WebP (lossy), GIF if ((applicableFormats & ImageFormats.WebPLossless) != 0) targetFormat = ImageFormat.WebPLossless; @@ -430,7 +435,7 @@ bool shouldApplyCircularMask using (var image = GetImageFile(imageId)) using (var stream = new MemoryStream()) { - TransformImage(stream, image, sourceRectangle, targetFormat, targetSize, shouldStripAnimations, shouldApplyCircularMask); + TransformImage(stream, image, sourceRectangle, targetFormat, targetPixelFormat, targetSize, shouldStripAnimations, shouldApplyCircularMask); var physicalImageId = XxHash128.HashToUInt128(stream.GetBuffer().AsSpan(0, (int)stream.Length), PhysicalImageIdHashSeed); string fileName = GetFileName(_imageCacheDirectory, physicalImageId); // Assume that if a file exists, it is already correct. We want to avoid wearing the disk if we don't need to. @@ -443,7 +448,17 @@ bool shouldApplyCircularMask } // TODO: Improve this method. Current operation is quick and dirty, but there are certainly better possibilities. - private void TransformImage(Stream stream, ImageFile originalImage, Rectangle sourceRectangle, ImageFormat targetFormat, Size targetSize, bool shouldStripAnimations, bool applyCircularMask) + private void TransformImage + ( + Stream stream, + ImageFile originalImage, + Rectangle sourceRectangle, + ImageFormat targetFormat, + PixelFormat targetPixelFormat, + Size targetSize, + bool shouldStripAnimations, + bool applyCircularMask + ) { using (var memoryManager = originalImage.CreateMemoryManager()) { @@ -490,6 +505,16 @@ private void TransformImage(Stream stream, ImageFile originalImage, Rectangle so } } + if (shouldStripAnimations && image.Frames.Count > 1) + { + // TODO: Also clear frame metadata related to animation. + do + { + image.Frames.RemoveFrame(image.Frames.Count - 1); + } + while (image.Frames.Count > 1); + } + image.Mutate ( ctx => @@ -514,8 +539,23 @@ private void TransformImage(Stream stream, ImageFile originalImage, Rectangle so switch (targetFormat) { case ImageFormat.Raw: - // Need to provide the pixel format here. - throw new NotImplementedException(); + if (targetPixelFormat == PixelFormat.B8G8R8A8 || targetPixelFormat == PixelFormat.B8G8R8X8) SaveRawImage(image, stream); + else if (targetPixelFormat == PixelFormat.R8G8B8A8 || targetPixelFormat == PixelFormat.R8G8B8X8) SaveRawImage(image, stream); + else if (targetPixelFormat == PixelFormat.A8R8G8B8 || targetPixelFormat == PixelFormat.X8R8G8B8) SaveRawImage(image, stream); + else if (targetPixelFormat == PixelFormat.A8B8G8R8 || targetPixelFormat == PixelFormat.X8B8G8R8) SaveRawImage(image, stream); + else if (targetPixelFormat == PixelFormat.B8G8R8) SaveRawImage(image, stream); + else if (targetPixelFormat == PixelFormat.R8G8B8) SaveRawImage(image, stream); + else throw new NotSupportedException("The specified image format is not yet supported."); + break; + case ImageFormat.Bitmap: + if (targetPixelFormat == PixelFormat.B8G8R8A8 || targetPixelFormat == PixelFormat.B8G8R8X8) SaveBitmapImage(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32); + else if (targetPixelFormat == PixelFormat.R8G8B8A8 || targetPixelFormat == PixelFormat.R8G8B8X8) SaveBitmapImage(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32); + else if (targetPixelFormat == PixelFormat.A8R8G8B8 || targetPixelFormat == PixelFormat.X8R8G8B8) SaveBitmapImage(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32); + else if (targetPixelFormat == PixelFormat.A8B8G8R8 || targetPixelFormat == PixelFormat.X8B8G8R8) SaveBitmapImage(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32); + else if (targetPixelFormat == PixelFormat.B8G8R8) SaveBitmapImage(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel24); + else if (targetPixelFormat == PixelFormat.R8G8B8) SaveBitmapImage(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel24); + else throw new NotSupportedException("The specified image format is not yet supported."); + break; case ImageFormat.Gif: SixLabors.ImageSharp.ImageExtensions.SaveAsGif(image, stream); break; @@ -533,6 +573,54 @@ private void TransformImage(Stream stream, ImageFile originalImage, Rectangle so } } + private static void SaveBitmapImage(SixLabors.ImageSharp.Image image, Stream stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel bitsPerPixel) + where TPixel : unmanaged, IPixel + { + var encoder = new SixLabors.ImageSharp.Formats.Bmp.BmpEncoder + { + BitsPerPixel = bitsPerPixel + }; + + if (image is SixLabors.ImageSharp.Image imageInCorrectFormat) + { + SixLabors.ImageSharp.ImageExtensions.SaveAsBmp(imageInCorrectFormat, stream, encoder); + } + else + { + using (imageInCorrectFormat = image.CloneAs()) + { + SixLabors.ImageSharp.ImageExtensions.SaveAsBmp(imageInCorrectFormat, stream, encoder); + } + } + } + + private static void SaveRawImage(SixLabors.ImageSharp.Image image, Stream stream) + where TPixel : unmanaged, IPixel + { + if (image is SixLabors.ImageSharp.Image imageInCorrectFormat) + { + WriteFrameDataToStream(stream, imageInCorrectFormat.Frames[0]); + } + else + { + using (imageInCorrectFormat = image.CloneAs()) + { + WriteFrameDataToStream(stream, imageInCorrectFormat.Frames[0]); + } + } + } + + private static void WriteFrameDataToStream(Stream stream, SixLabors.ImageSharp.ImageFrame frame) + where TPixel : unmanaged, IPixel + { + int height = frame.Height; + for (int i = 0; i < height; i++) + { + var row = frame.DangerousGetPixelRowMemory(i); + stream.Write(MemoryMarshal.Cast(row.Span)); + } + } + public async ValueTask HasImageAsync(string imageName, CancellationToken cancellationToken) { if (!ImageNameSerializer.IsNameValid(imageName)) throw new ArgumentException("Invalid name."); diff --git a/src/Exo/Ui/Exo.Settings.Ui/ChangedProperty.cs b/src/Exo/Ui/Exo.Settings.Ui/ChangedProperty.cs index 41764761..fb1030d7 100644 --- a/src/Exo/Ui/Exo.Settings.Ui/ChangedProperty.cs +++ b/src/Exo/Ui/Exo.Settings.Ui/ChangedProperty.cs @@ -75,4 +75,5 @@ internal static class ChangedProperty public static readonly PropertyChangedEventArgs CropRectangle = new (nameof(CropRectangle)); public static readonly PropertyChangedEventArgs HasBuiltInGraphics = new (nameof(HasBuiltInGraphics)); public static readonly PropertyChangedEventArgs CurrentGraphics = new (nameof(CurrentGraphics)); + public static readonly PropertyChangedEventArgs SelectedMonitor = new (nameof(SelectedMonitor)); } diff --git a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorDeviceSettingsControl.xaml b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorDeviceSettingsControl.xaml index cacecd49..b2869d73 100644 --- a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorDeviceSettingsControl.xaml +++ b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorDeviceSettingsControl.xaml @@ -17,28 +17,49 @@ - + - - - - - - - - + + + + + + + - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml index 218a8817..a645d778 100644 --- a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml +++ b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml @@ -10,7 +10,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - + @@ -27,7 +27,7 @@ @@ -39,8 +39,8 @@ - - + + diff --git a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml.cs b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml.cs index 18ea97df..9016f9aa 100644 --- a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml.cs +++ b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorImageSettingsControl.xaml.cs @@ -11,6 +11,7 @@ namespace Exo.Settings.Ui; internal sealed partial class EmbeddedMonitorImageSettingsControl : UserControl { + // For some reason, XAML won't do any type check and will mess up everything by assigning the wrong type here sometimes… public EmbeddedMonitorImageGraphicsViewModel? ImageGraphics { get => (EmbeddedMonitorImageGraphicsViewModel)GetValue(ImageGraphicsProperty); diff --git a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorSettingControl.xaml b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorSettingControl.xaml index 7db5f431..316366d8 100644 --- a/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorSettingControl.xaml +++ b/src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorSettingControl.xaml @@ -41,7 +41,7 @@ - + @@ -50,8 +50,8 @@ - - + + diff --git a/src/Exo/Ui/Exo.Settings.Ui/ViewModels/EmbeddedMonitorFeaturesViewModel.cs b/src/Exo/Ui/Exo.Settings.Ui/ViewModels/EmbeddedMonitorFeaturesViewModel.cs index 15d8eb40..e11b3b9b 100644 --- a/src/Exo/Ui/Exo.Settings.Ui/ViewModels/EmbeddedMonitorFeaturesViewModel.cs +++ b/src/Exo/Ui/Exo.Settings.Ui/ViewModels/EmbeddedMonitorFeaturesViewModel.cs @@ -20,6 +20,7 @@ internal sealed class EmbeddedMonitorFeaturesViewModel : BindableObject, IDispos private readonly Dictionary _pendingConfigurationUpdates; private bool _isExpanded; private readonly PropertyChangedEventHandler _onRasterizationScaleProviderPropertyChanged; + private EmbeddedMonitorViewModel? _selectedMonitor; public EmbeddedMonitorFeaturesViewModel ( @@ -68,6 +69,13 @@ public bool IsExpanded set => SetValue(ref _isExpanded, value, ChangedProperty.IsExpanded); } + // This is used when showing monitors in a grid. + public EmbeddedMonitorViewModel? SelectedMonitor + { + get => _selectedMonitor; + set => SetValue(ref _selectedMonitor, value, ChangedProperty.SelectedMonitor); + } + internal IEmbeddedMonitorService EmbeddedMonitorService => _embeddedMonitorService; internal void UpdateInformation(EmbeddedMonitorDeviceInformation information) @@ -235,7 +243,12 @@ private EmbeddedMonitorCapabilities Capabilities public EmbeddedMonitorGraphicsViewModel? CurrentGraphics { get => _currentGraphics; - set => SetChangeableValue(ref _currentGraphics, value, ChangedProperty.CurrentGraphics); + set + { + // Need to ignore null values for the 🤬 bindings behaving completely irrationally. + if (value is null) return; + SetChangeableValue(ref _currentGraphics, value, ChangedProperty.CurrentGraphics); + } } internal void UpdateInformation(EmbeddedMonitorInformation information)