Skip to content

Commit

Permalink
✨ Improve support for StreamDeck monitors.
Browse files Browse the repository at this point in the history
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 😅
  • Loading branch information
hexawyz committed Feb 9, 2025
1 parent 8e03417 commit 7611bac
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte> 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))));
}
Expand Down
1 change: 1 addition & 0 deletions src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
100 changes: 94 additions & 6 deletions src/Exo/Service/Exo.Service.Core/ImageStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -329,6 +331,7 @@ public ImageFile GetImageFile(UInt128 imageId)
Rectangle sourceRectangle,
ImageFormats targetStaticFormats,
ImageFormats targetAnimatedFormats,
PixelFormat targetPixelFormat,
Size targetSize,
bool shouldApplyCircularMask
)
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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())
{
Expand Down Expand Up @@ -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 =>
Expand All @@ -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<Bgra32>(image, stream);
else if (targetPixelFormat == PixelFormat.R8G8B8A8 || targetPixelFormat == PixelFormat.R8G8B8X8) SaveRawImage<Rgba32>(image, stream);
else if (targetPixelFormat == PixelFormat.A8R8G8B8 || targetPixelFormat == PixelFormat.X8R8G8B8) SaveRawImage<Argb32>(image, stream);
else if (targetPixelFormat == PixelFormat.A8B8G8R8 || targetPixelFormat == PixelFormat.X8B8G8R8) SaveRawImage<Abgr32>(image, stream);
else if (targetPixelFormat == PixelFormat.B8G8R8) SaveRawImage<Bgr24>(image, stream);
else if (targetPixelFormat == PixelFormat.R8G8B8) SaveRawImage<Rgb24>(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<Bgra32>(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32);
else if (targetPixelFormat == PixelFormat.R8G8B8A8 || targetPixelFormat == PixelFormat.R8G8B8X8) SaveBitmapImage<Rgba32>(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32);
else if (targetPixelFormat == PixelFormat.A8R8G8B8 || targetPixelFormat == PixelFormat.X8R8G8B8) SaveBitmapImage<Argb32>(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32);
else if (targetPixelFormat == PixelFormat.A8B8G8R8 || targetPixelFormat == PixelFormat.X8B8G8R8) SaveBitmapImage<Abgr32>(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel32);
else if (targetPixelFormat == PixelFormat.B8G8R8) SaveBitmapImage<Bgr24>(image, stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel.Pixel24);
else if (targetPixelFormat == PixelFormat.R8G8B8) SaveBitmapImage<Rgb24>(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;
Expand All @@ -533,6 +573,54 @@ private void TransformImage(Stream stream, ImageFile originalImage, Rectangle so
}
}

private static void SaveBitmapImage<TPixel>(SixLabors.ImageSharp.Image image, Stream stream, SixLabors.ImageSharp.Formats.Bmp.BmpBitsPerPixel bitsPerPixel)
where TPixel : unmanaged, IPixel<TPixel>
{
var encoder = new SixLabors.ImageSharp.Formats.Bmp.BmpEncoder
{
BitsPerPixel = bitsPerPixel
};

if (image is SixLabors.ImageSharp.Image<TPixel> imageInCorrectFormat)
{
SixLabors.ImageSharp.ImageExtensions.SaveAsBmp(imageInCorrectFormat, stream, encoder);
}
else
{
using (imageInCorrectFormat = image.CloneAs<TPixel>())
{
SixLabors.ImageSharp.ImageExtensions.SaveAsBmp(imageInCorrectFormat, stream, encoder);
}
}
}

private static void SaveRawImage<TPixel>(SixLabors.ImageSharp.Image image, Stream stream)
where TPixel : unmanaged, IPixel<TPixel>
{
if (image is SixLabors.ImageSharp.Image<TPixel> imageInCorrectFormat)
{
WriteFrameDataToStream(stream, imageInCorrectFormat.Frames[0]);
}
else
{
using (imageInCorrectFormat = image.CloneAs<TPixel>())
{
WriteFrameDataToStream(stream, imageInCorrectFormat.Frames[0]);
}
}
}

private static void WriteFrameDataToStream<TPixel>(Stream stream, SixLabors.ImageSharp.ImageFrame<TPixel> frame)
where TPixel : unmanaged, IPixel<TPixel>
{
int height = frame.Height;
for (int i = 0; i < height; i++)
{
var row = frame.DangerousGetPixelRowMemory(i);
stream.Write(MemoryMarshal.Cast<TPixel, byte>(row.Span));
}
}

public async ValueTask<bool> HasImageAsync(string imageName, CancellationToken cancellationToken)
{
if (!ImageNameSerializer.IsNameValid(imageName)) throw new ArgumentException("Invalid name.");
Expand Down
1 change: 1 addition & 0 deletions src/Exo/Ui/Exo.Settings.Ui/ChangedProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,49 @@
<lts:EmbeddedMonitorSettingTemplateSelector>
<lts:EmbeddedMonitorSettingTemplateSelector.SingleMonitorTemplate>
<DataTemplate x:DataType="vm:EmbeddedMonitorFeaturesViewModel">
<local:EmbeddedMonitorSettingControl Monitor="{Binding EmbeddedMonitors[0]}" IsEnabled="{Binding EmbeddedMonitors[0].IsNotBusy}" />
<local:EmbeddedMonitorSettingControl Monitor="{x:Bind EmbeddedMonitors[0]}" IsEnabled="{x:Bind EmbeddedMonitors[0].IsNotBusy}" />
</DataTemplate>
</lts:EmbeddedMonitorSettingTemplateSelector.SingleMonitorTemplate>
<lts:EmbeddedMonitorSettingTemplateSelector.MonitorMatrixTemplate>
<DataTemplate x:DataType="vm:EmbeddedMonitorFeaturesViewModel">
<Grid>
<!-- TODO: Create a Control to display items in a fixed grid -->
<ItemsView ItemsSource="{Binding EmbeddedMonitors}">
<ItemsView.ItemTemplate>
<DataTemplate x:DataType="vm:EmbeddedMonitorViewModel">
<ItemContainer>
<Grid Width="{Binding DisplayWidth}" Height="{Binding DisplayHeight}" Background="Black" Margin="3">
<Image></Image>
<StackPanel Orientation="Vertical">
<ScrollViewer VerticalScrollMode="Disabled" HorizontalScrollMode="Auto" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center">
<ListView
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
SelectedItem="{x:Bind SelectedMonitor, Mode=TwoWay}"
ItemsSource="{x:Bind EmbeddedMonitors, Mode=OneTime}">
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:EmbeddedMonitorViewModel">
<Grid Width="{x:Bind DisplayWidth, Mode=OneWay}" Height="{x:Bind DisplayHeight, Mode=OneWay}" Background="Black" Margin="3">
<Image Source="{Binding CurrentGraphics.Image.FileName, Converter={StaticResource FileNameToBitmapImageConverter}, Mode=OneWay}" />
</Grid>
</ItemContainer>
</DataTemplate>
</ItemsView.ItemTemplate>
<ItemsView.Layout>
<UniformGridLayout MaximumRowsOrColumns="8" />
</ItemsView.Layout>
</ItemsView>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource DefaultListBoxItemStyle}">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<controls:UniformGrid
HorizontalAlignment="Center"
FlowDirection="LeftToRight"
Orientation="Horizontal"
Columns="8"
Rows="4"
ColumnSpacing="2"
RowSpacing="2" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
</ScrollViewer>
<local:EmbeddedMonitorSettingControl Monitor="{x:Bind SelectedMonitor, Mode=OneWay}" IsEnabled="{x:Bind SelectedMonitor.IsNotBusy}" />
</StackPanel>
</DataTemplate>
</lts:EmbeddedMonitorSettingTemplateSelector.MonitorMatrixTemplate>
<lts:EmbeddedMonitorSettingTemplateSelector.MultiMonitorTemplate>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">

<Grid HorizontalAlignment="Stretch" DataContext="{x:Bind ImageGraphics}">
<Grid HorizontalAlignment="Stretch" DataContext="{x:Bind ImageGraphics, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
Expand All @@ -27,7 +27,7 @@
<ComboBox
Grid.Column="1"
Margin="{StaticResource RowContentLabelMargin}"
ItemsSource="{Binding AvailableImages}"
ItemsSource="{Binding AvailableImages, Mode=OneWay}"
SelectedItem="{Binding Image, Mode=TwoWay}"
Padding="10,3,0,3"
HorizontalAlignment="Stretch">
Expand All @@ -39,8 +39,8 @@
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ImageViewModel">
<StackPanel Orientation="Horizontal">
<Image Source="{Binding FileName, Converter={StaticResource FileNameToBitmapImageConverter}}" Stretch="Uniform" Width="26" Height="26" Margin="0,0,6,0" />
<TextBlock Text="{Binding Name}" Margin="0,2,0,0" />
<Image Source="{x:Bind FileName, Converter={StaticResource FileNameToBitmapImageConverter}, Mode=OneTime}" Stretch="Uniform" Width="26" Height="26" Margin="0,0,6,0" />
<TextBlock Text="{x:Bind Name, Mode=OneTime}" Margin="0,2,0,0" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/Exo/Ui/Exo.Settings.Ui/EmbeddedMonitorSettingControl.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<FontIcon Glyph="&#xE777;" />
</Button>
</Grid>
<ContentControl Grid.Row="1" Content="{Binding CurrentGraphics}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<ContentControl Grid.Row="1" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" DataContext="{Binding CurrentGraphics, Mode=OneWay}" Content="{Binding}">
<ContentControl.ContentTemplateSelector>
<lts:EmbeddedMonitorGraphicsSettingTemplateSelector>
<lts:EmbeddedMonitorGraphicsSettingTemplateSelector.BuiltInTemplate>
Expand All @@ -50,8 +50,8 @@
</DataTemplate>
</lts:EmbeddedMonitorGraphicsSettingTemplateSelector.BuiltInTemplate>
<lts:EmbeddedMonitorGraphicsSettingTemplateSelector.ImageTemplate>
<DataTemplate>
<local:EmbeddedMonitorImageSettingsControl ImageGraphics="{Binding}" />
<DataTemplate x:DataType="vm:EmbeddedMonitorImageGraphicsViewModel">
<local:EmbeddedMonitorImageSettingsControl ImageGraphics="{x:Bind Mode=OneWay}" />
</DataTemplate>
</lts:EmbeddedMonitorGraphicsSettingTemplateSelector.ImageTemplate>
</lts:EmbeddedMonitorGraphicsSettingTemplateSelector>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal sealed class EmbeddedMonitorFeaturesViewModel : BindableObject, IDispos
private readonly Dictionary<Guid, EmbeddedMonitorConfigurationUpdate> _pendingConfigurationUpdates;
private bool _isExpanded;
private readonly PropertyChangedEventHandler _onRasterizationScaleProviderPropertyChanged;
private EmbeddedMonitorViewModel? _selectedMonitor;

public EmbeddedMonitorFeaturesViewModel
(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 7611bac

Please sign in to comment.