diff --git a/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs b/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs
index b7411975..0f14bc15 100644
--- a/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs
+++ b/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs
@@ -117,9 +117,10 @@ public interface IEmbeddedMonitorBuiltInGraphics : IEmbeddedMonitor
public readonly record struct EmbeddedMonitorInformation
{
- public EmbeddedMonitorInformation(MonitorShape shape, Size imageSize, PixelFormat pixelFormat, ImageFormats supportedImageFormats, bool hasAnimationSupport)
+ public EmbeddedMonitorInformation(MonitorShape shape, ImageRotation defaultRotation, Size imageSize, PixelFormat pixelFormat, ImageFormats supportedImageFormats, bool hasAnimationSupport)
{
Shape = shape;
+ DefaultRotation = defaultRotation;
ImageSize = imageSize;
PixelFormat = pixelFormat;
SupportedImageFormats = supportedImageFormats;
@@ -132,6 +133,12 @@ public EmbeddedMonitorInformation(MonitorShape shape, Size imageSize, PixelForma
/// The shape of the monitor might mainly be used to optimize image compression if the monitor is non-rectangular.
///
public MonitorShape Shape { get; }
+ /// Gets the default rotation of the monitor.
+ ///
+ /// Some monitors are by design put in a non-native orientation. As such, the proper display of images on these monitors require preliminary rotation.
+ /// This is, for example, the case of displays in some StreamDeck devices.
+ ///
+ public ImageRotation DefaultRotation { get; }
/// Gets the image size of the monitor.
public Size ImageSize { get; }
/// Gets the effective pixel format of the monitor.
diff --git a/src/Exo/Core/Exo.Core/Images/ImageRotation.cs b/src/Exo/Core/Exo.Core/Images/ImageRotation.cs
new file mode 100644
index 00000000..2cae0c69
--- /dev/null
+++ b/src/Exo/Core/Exo.Core/Images/ImageRotation.cs
@@ -0,0 +1,9 @@
+namespace Exo.Images;
+
+public enum ImageRotation : byte
+{
+ None,
+ Rotate90,
+ Rotate180,
+ Rotate270,
+}
diff --git a/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs b/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs
index 652b61a1..d507e24f 100644
--- a/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs
+++ b/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs
@@ -268,7 +268,8 @@ public Button(StreamDeckDeviceDriver driver, byte buttonId)
Guid IEmbeddedMonitor.MonitorId => _driver._buttonIds[_keyIndex];
// 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);
+ EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation
+ => new(MonitorShape.Square, ImageRotation.Rotate180, _driver.ButtonImageSize, PixelFormat.B8G8R8, /*ImageFormats.Bitmap | */ImageFormats.Jpeg, false);
async ValueTask IEmbeddedMonitor.SetImageAsync(UInt128 imageId, ImageFormat imageFormat, ReadOnlyMemory data, CancellationToken cancellationToken)
{
diff --git a/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs b/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs
index 808314f7..b6644337 100644
--- a/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs
+++ b/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs
@@ -284,7 +284,7 @@ static uint GetColor(byte r, byte g, byte b)
ImmutableArray ICoolingControllerFeature.Coolers => ImmutableCollectionsMarshal.AsImmutableArray(_coolers);
Guid IEmbeddedMonitor.MonitorId => MonitorId;
- EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Circle, new(_imageWidth, _imageHeight), PixelFormat.R8G8B8X8, ImageFormats.Raw | ImageFormats.Gif, true);
+ EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Circle, ImageRotation.None, new(_imageWidth, _imageHeight), PixelFormat.R8G8B8X8, ImageFormats.Raw | ImageFormats.Gif, true);
IDeviceFeatureSet IDeviceDriver.Features => _genericFeatures;
IDeviceFeatureSet IDeviceDriver.Features => _sensorFeatures;
diff --git a/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs b/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs
index dc82ffff..f6d32b81 100644
--- a/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs
+++ b/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs
@@ -87,6 +87,7 @@ private sealed class EmbeddedMonitorState
// Metadata
private readonly Guid _id;
private MonitorShape _shape;
+ private ImageRotation _defaultRotation;
private ushort _width;
private ushort _height;
private PixelFormat _pixelFormat;
@@ -105,6 +106,7 @@ public EmbeddedMonitorState
(
Guid id,
MonitorShape shape,
+ ImageRotation defaultRotation,
ushort width,
ushort height,
PixelFormat pixelFormat,
@@ -116,6 +118,7 @@ PersistedMonitorConfiguration configuration
{
_id = id;
_shape = shape;
+ _defaultRotation = defaultRotation;
_width = width;
_height = height;
_pixelFormat = pixelFormat;
@@ -185,6 +188,11 @@ public bool SetOnline(IEmbeddedMonitor monitor)
_shape = information.Shape;
hasChanged = true;
}
+ if (_defaultRotation != information.DefaultRotation)
+ {
+ _defaultRotation = information.DefaultRotation;
+ hasChanged = true;
+ }
if (_width != information.ImageSize.Width)
{
_width = checked((ushort)information.ImageSize.Width);
@@ -292,6 +300,7 @@ private async Task SetImageAsyncCore(ImageStorageService imageStorageService, UI
(_capabilities & EmbeddedMonitorCapabilities.AnimatedImages) != 0 ? _imageFormats & ImageFormats.Gif : 0,
_pixelFormat,
new(_width, _height),
+ _defaultRotation,
_shape == MonitorShape.Circle
);
using (imageFile)
@@ -329,6 +338,7 @@ public PersistedEmbeddedMonitorInformation CreatePersistedInformation()
=> new()
{
Shape = _shape,
+ DefaultRotation = _defaultRotation,
Width = _width,
Height = _height,
PixelFormat = _pixelFormat,
@@ -342,6 +352,7 @@ public EmbeddedMonitorInformation CreateInformation()
{
MonitorId = _id,
Shape = _shape,
+ DefaultRotation = _defaultRotation,
ImageSize = new(_width, _height),
PixelFormat = _pixelFormat,
SupportedImageFormats = _imageFormats,
@@ -377,6 +388,7 @@ public EmbeddedMonitorConfigurationWatchNotification CreateConfigurationNotifica
private readonly struct PersistedEmbeddedMonitorInformation
{
public required MonitorShape Shape { get; init; }
+ public required ImageRotation DefaultRotation { get; init; }
public required ushort Width { get; init; }
public required ushort Height { get; init; }
public required PixelFormat PixelFormat { get; init; }
@@ -444,6 +456,7 @@ CancellationToken cancellationToken
(
embeddedMonitorId,
info.Shape,
+ info.DefaultRotation,
info.Width,
info.Height,
info.PixelFormat,
@@ -837,6 +850,7 @@ public readonly struct EmbeddedMonitorInformation
{
public required Guid MonitorId { get; init; }
public required MonitorShape Shape { get; init; }
+ public required ImageRotation DefaultRotation { get; init; }
public required Size ImageSize { get; init; }
public required PixelFormat PixelFormat { get; init; }
public required ImageFormats SupportedImageFormats { get; init; }
diff --git a/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs b/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs
index dc82cc6a..3734b852 100644
--- a/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs
+++ b/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs
@@ -333,6 +333,7 @@ public ImageFile GetImageFile(UInt128 imageId)
ImageFormats targetAnimatedFormats,
PixelFormat targetPixelFormat,
Size targetSize,
+ ImageRotation targetRotation,
bool shouldApplyCircularMask
)
{
@@ -344,12 +345,13 @@ bool shouldApplyCircularMask
if (sourceRectangle.Left + sourceRectangle.Width > metadata.Width || sourceRectangle.Top + sourceRectangle.Height > metadata.Height) throw new ArgumentException(nameof(sourceRectangle));
// Determine now if the image already has the correct size. We want to avoid reprocessing an image that may already be correct.
- bool isCorrectSize = sourceRectangle.Left == 0 &&
- sourceRectangle.Top == 0 &&
- sourceRectangle.Width == metadata.Width &&
- sourceRectangle.Height == metadata.Height &&
- targetSize.Width == metadata.Width &&
- targetSize.Height == metadata.Height;
+ bool shouldCrop = sourceRectangle.Left != 0 ||
+ sourceRectangle.Top != 0 ||
+ sourceRectangle.Width != metadata.Width ||
+ sourceRectangle.Height != metadata.Height;
+ bool shouldResize = targetSize.Width == sourceRectangle.Width &&
+ targetSize.Height == sourceRectangle.Height;
+ bool isCorrectSize = !(shouldCrop || shouldResize);
// 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;
@@ -400,15 +402,19 @@ bool shouldApplyCircularMask
// If we re-processed the image everytime, we would prevent this.
if (targetFormat == ImageFormat.Gif && isCorrectSize) shouldApplyCircularMask = false;
- Span payload = stackalloc byte[30];
+ var operations = ImageOperations.None;
+
+ if (shouldCrop) operations |= ImageOperations.Crop;
+ if (shouldResize) operations |= ImageOperations.Resize;
+ if (shouldApplyCircularMask) operations |= ImageOperations.CircularMask;
+ if (shouldStripAnimations) operations |= ImageOperations.StripAnimation;
- payload[0] = (byte)
- (
- shouldApplyCircularMask ?
- (shouldStripAnimations ? ImageOperationType.CircularTargetAdjustStripAnimation : ImageOperationType.CircularTargetAdjust) :
- (shouldStripAnimations ? ImageOperationType.RectangularTargetAdjustStripAnimation : ImageOperationType.RectangularTargetAdjust)
- );
+ // ⚠️ Just straight output the rotation here. Multiplication should be optimized to a shift by the JIT, but we keep sync with enum values that way.
+ operations |= (ImageOperations)((uint)targetRotation * (uint)ImageOperations.Rotate90);
+ Span payload = stackalloc byte[30];
+
+ payload[0] = (byte)operations;
payload[1] = (byte)targetFormat;
LittleEndian.Write(ref payload[2], imageId);
LittleEndian.Write(ref payload[18], (ushort)sourceRectangle.Left);
@@ -435,7 +441,7 @@ bool shouldApplyCircularMask
using (var image = GetImageFile(imageId))
using (var stream = new MemoryStream())
{
- TransformImage(stream, image, sourceRectangle, targetFormat, targetPixelFormat, targetSize, shouldStripAnimations, shouldApplyCircularMask);
+ TransformImage(stream, operations, image, sourceRectangle, targetFormat, targetPixelFormat, targetSize);
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.
@@ -451,13 +457,12 @@ bool shouldApplyCircularMask
private void TransformImage
(
Stream stream,
+ ImageOperations operations,
ImageFile originalImage,
Rectangle sourceRectangle,
ImageFormat targetFormat,
PixelFormat targetPixelFormat,
- Size targetSize,
- bool shouldStripAnimations,
- bool applyCircularMask
+ Size targetSize
)
{
using (var memoryManager = originalImage.CreateMemoryManager())
@@ -505,7 +510,7 @@ bool applyCircularMask
}
}
- if (shouldStripAnimations && image.Frames.Count > 1)
+ if ((operations & ImageOperations.StripAnimation) != 0 && image.Frames.Count > 1)
{
// TODO: Also clear frame metadata related to animation.
do
@@ -519,18 +524,30 @@ bool applyCircularMask
(
ctx =>
{
- ctx.AutoOrient()
- .Crop(new(sourceRectangle.Left, sourceRectangle.Top, sourceRectangle.Width, sourceRectangle.Height))
- .Resize
+ // TODO: Merge this with rotation.
+ // Basically, it should be possible to avoid explicit rotation if the requested rotation already matches the EXIF rotation.
+ // Meaning that EXIF rotation should be added to image metadata.
+ ctx.AutoOrient();
+
+ if ((operations & ImageOperations.Crop) != 0) ctx.Crop(new(sourceRectangle.Left, sourceRectangle.Top, sourceRectangle.Width, sourceRectangle.Height));
+ if ((operations & ImageOperations.Resize) != 0)
+ {
+ ctx.Resize
(
targetSize.Width,
targetSize.Height,
resampler,
true
);
- if (applyCircularMask)
+ }
+ var rotation = (ImageRotation)((uint)(operations & ImageOperations.Rotate270) / (uint)ImageOperations.Rotate90);
+ if (rotation != ImageRotation.None)
+ {
+ ctx.Rotate((RotateMode)(90 * (uint)rotation));
+ }
+ if ((operations & ImageOperations.CircularMask) != 0)
{
- // TODO: For GIF, should find the index of any existing
+ // TODO: For GIF, selected color could still be improved to pick a pixel already within the circle.
ctx.ApplyProcessor(new CircleCroppingProcessor(maskColor, (byte)(targetFormat == ImageFormat.Jpeg ? 3 : 2)));
}
}
@@ -728,11 +745,20 @@ public async ValueTask RemoveImageAsync(string imageName, CancellationToken canc
}
}
-internal enum ImageOperationType : byte
+[Flags]
+internal enum ImageOperations : byte
{
- Unknown = 0,
- RectangularTargetAdjust = 1,
- RectangularTargetAdjustStripAnimation = 2,
- CircularTargetAdjust = 3,
- CircularTargetAdjustStripAnimation = 4,
+ None = 0,
+
+ // One bit these individual operations that can be applied to images for device adaptation.
+ Crop = 1,
+ Resize = 2,
+ CircularMask = 4,
+ StripAnimation = 8,
+
+ // Two bits for rotations
+ // ⚠️ Be careful that these values must be synchronized with ImageRotation. We must be able to quickly insert and extract those.
+ Rotate90 = 16,
+ Rotate180 = 32,
+ Rotate270 = 48,
}