diff --git a/src/SamplesApp/UITests.Shared/Assets/testimage_exif_rotated_different_dimensions.jpg b/src/SamplesApp/UITests.Shared/Assets/testimage_exif_rotated_different_dimensions.jpg
new file mode 100644
index 000000000000..f1af1be503b5
Binary files /dev/null and b/src/SamplesApp/UITests.Shared/Assets/testimage_exif_rotated_different_dimensions.jpg differ
diff --git a/src/SamplesApp/UITests.Shared/UITests.Shared.projitems b/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
index d1e0521af33d..9e319807ba09 100644
--- a/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
+++ b/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
@@ -9612,6 +9612,7 @@
+
diff --git a/src/Uno.UI.Composition/Composition/FrameProviderFactory.skia.cs b/src/Uno.UI.Composition/Composition/FrameProviderFactory.skia.cs
new file mode 100644
index 000000000000..69509749eb53
--- /dev/null
+++ b/src/Uno.UI.Composition/Composition/FrameProviderFactory.skia.cs
@@ -0,0 +1,107 @@
+#nullable enable
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using SkiaSharp;
+
+namespace Microsoft.UI.Composition;
+
+internal static class FrameProviderFactory
+{
+ public static IFrameProvider Create(SKImage image)
+ => new SingleFrameProvider(image);
+
+ public static bool TryCreate(SKManagedStream stream, Action onFrameChanged, [NotNullWhen(true)] out IFrameProvider? provider)
+ {
+ using var codec = SKCodec.Create(stream);
+ var imageInfo = codec.Info;
+ var frameInfos = codec.FrameInfo;
+ imageInfo = new SKImageInfo(imageInfo.Width, imageInfo.Height, SKColorType.Bgra8888, SKAlphaType.Premul);
+ using var bitmap = new SKBitmap(imageInfo);
+
+ if (codec.FrameInfo.Length < 2)
+ {
+ // FrameInfo can be zero for single-frame images
+ codec.GetPixels(imageInfo, bitmap.GetPixels());
+ provider = new SingleFrameProvider(GetImage(bitmap, codec.EncodedOrigin));
+ return true;
+ }
+
+ var images = GC.AllocateUninitializedArray(frameInfos.Length);
+ var totalDuration = 0;
+ for (int i = 0; i < frameInfos.Length; i++)
+ {
+ var options = new SKCodecOptions(i);
+ codec.GetPixels(imageInfo, bitmap.GetPixels(), options);
+
+ var currentBitmap = GetImage(bitmap, codec.EncodedOrigin);
+ if (currentBitmap is null)
+ {
+ provider = null;
+ return false;
+ }
+
+ images[i] = currentBitmap;
+ totalDuration += frameInfos[i].Duration;
+ }
+
+ provider = new GifFrameProvider(images, frameInfos, totalDuration, onFrameChanged);
+ return true;
+ }
+
+ private static SKImage GetImage(SKBitmap bitmap, SKEncodedOrigin origin)
+ {
+ var info = bitmap.Info;
+ if (SkEncodedOriginSwapsWidthHeight(origin))
+ {
+ info = new SKImageInfo(info.Height, info.Width, SKColorType.Bgra8888, SKAlphaType.Premul);
+ }
+
+ var matrix = GetExifMatrix(origin, info.Width, info.Height);
+ if (matrix.IsIdentity)
+ {
+ return SKImage.FromBitmap(bitmap);
+ }
+
+ var newBitmap = new SKBitmap(info);
+ using var canvas = new SKCanvas(newBitmap);
+ canvas.SetMatrix(matrix);
+ canvas.DrawBitmap(bitmap, 0, 0);
+ return SKImage.FromBitmap(newBitmap);
+ }
+
+ // https://github.com/google/skia/blob/b20651c1aad43e3447830d6ce7a68ca507b398a4/include/codec/SkEncodedOrigin.h#L32-L42
+ private static SKMatrix GetExifMatrix(SKEncodedOrigin origin, int width, int height)
+ {
+ return origin switch
+ {
+ SKEncodedOrigin.TopLeft => SKMatrix.Identity,
+ SKEncodedOrigin.TopRight => new SKMatrix(-1, 0, width, 0, 1, 0, 0, 0, 1),
+ SKEncodedOrigin.BottomRight => new SKMatrix(-1, 0, width, 0, -1, height, 0, 0, 1),
+ SKEncodedOrigin.BottomLeft => new SKMatrix(1, 0, 0, 0, -1, height, 0, 0, 1),
+ SKEncodedOrigin.LeftTop => new SKMatrix(0, 1, 0, 1, 0, 0, 0, 0, 1),
+ SKEncodedOrigin.RightTop => new SKMatrix(0, -1, width, 1, 0, 0, 0, 0, 1),
+ SKEncodedOrigin.RightBottom => new SKMatrix(0, -1, width, -1, 0, height, 0, 0, 1),
+ SKEncodedOrigin.LeftBottom => new SKMatrix(0, 1, 0, -1, 0, height, 0, 0, 1),
+ _ => throw new ArgumentException($"Unexpected SKEncodedOrigin value '{origin}'.", nameof(origin)),
+ };
+ }
+
+ private static bool SkEncodedOriginSwapsWidthHeight(SKEncodedOrigin origin)
+ {
+ return origin is
+ // Reflected across x - axis.Rotated 90� counter - clockwise.
+ SKEncodedOrigin.LeftTop or
+
+ // Rotated 90� clockwise.
+ SKEncodedOrigin.RightTop or
+
+ // Reflected across x-axis. Rotated 90� clockwise.
+ SKEncodedOrigin.RightBottom or
+
+ // Rotated 90� counter-clockwise.
+ SKEncodedOrigin.LeftBottom;
+ }
+
+}
diff --git a/src/Uno.UI.Composition/Composition/GifFrameProvider.skia.cs b/src/Uno.UI.Composition/Composition/GifFrameProvider.skia.cs
new file mode 100644
index 000000000000..92c2cb060442
--- /dev/null
+++ b/src/Uno.UI.Composition/Composition/GifFrameProvider.skia.cs
@@ -0,0 +1,116 @@
+#nullable enable
+
+using System;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using SkiaSharp;
+
+namespace Microsoft.UI.Composition;
+
+internal sealed class GifFrameProvider : IFrameProvider
+{
+ private readonly SKImage[] _images;
+ private readonly SKCodecFrameInfo[]? _frameInfos;
+ private readonly Timer? _timer;
+ private readonly Stopwatch? _stopwatch;
+ private readonly long _totalDuration;
+ private readonly WeakReference _onFrameChanged;
+
+ private int _currentFrame;
+ private bool _disposed;
+
+ // Note: The Timer will keep holding onto the ImageFrameProvider until stopped (it's a static root).
+ // But we only stop the timer when we dispose ImageFrameProvider from SkiaCompositionSurface finalizer.
+ // The onFrameChanged Action is also holding onto SkiaCompositionSurface.
+ // So, if ImageFrameProvider holds onto onFrameChanged, the SkiaCompositionSurface is never GC'ed.
+ // That's why we make it a WeakReference.
+ // Note that SkiaCompositionSurface keeps an unused private field storing onFrameChanged so that it's not GC'ed early.
+ internal GifFrameProvider(SKImage[] images, SKCodecFrameInfo[] frameInfos, long totalDuration, Action onFrameChanged)
+ {
+ _images = images;
+ _frameInfos = frameInfos;
+ _totalDuration = totalDuration;
+ _onFrameChanged = new WeakReference(onFrameChanged);
+ Debug.Assert(images.Length > 1);
+ Debug.Assert(frameInfos is not null);
+ Debug.Assert(totalDuration != 0);
+ Debug.Assert(onFrameChanged is not null);
+
+ if (_images.Length < 2)
+ {
+ throw new ArgumentException("GifFrameProvider should only be used when there is at least two frames");
+ }
+
+ _stopwatch = Stopwatch.StartNew();
+ _timer = new Timer(OnTimerCallback, null, dueTime: _frameInfos![0].Duration, period: Timeout.Infinite);
+ }
+
+ public SKImage? CurrentImage => _images[_currentFrame];
+
+ private int GetCurrentFrameIndex()
+ {
+ var currentTimestampInMilliseconds = _stopwatch!.ElapsedMilliseconds % _totalDuration;
+ for (int i = 0; i < _frameInfos!.Length; i++)
+ {
+ if (currentTimestampInMilliseconds < _frameInfos[i].Duration)
+ {
+ return i;
+ }
+
+ currentTimestampInMilliseconds -= _frameInfos[i].Duration;
+ }
+
+ throw new InvalidOperationException("This shouldn't be reachable. A timestamp in total duration range should map to a frame");
+ }
+
+ private void SetCurrentFrame()
+ {
+ var frameIndex = GetCurrentFrameIndex();
+ if (_currentFrame != frameIndex)
+ {
+ _currentFrame = frameIndex;
+ Debug.Assert(_onFrameChanged is not null);
+ if (_onFrameChanged.TryGetTarget(out var onFrameChanged))
+ {
+ onFrameChanged();
+ }
+ }
+ }
+
+ private void OnTimerCallback(object? state)
+ {
+ SetCurrentFrame();
+
+ var timestamp = _stopwatch!.ElapsedMilliseconds % _totalDuration;
+ var nextFrameTimeStamp = 0;
+ for (int i = 0; i <= _currentFrame; i++)
+ {
+ nextFrameTimeStamp += _frameInfos![i].Duration;
+ }
+
+ var dueTime = nextFrameTimeStamp - timestamp;
+ if (dueTime < 0)
+ {
+ // Defensive check. When pausing the program for debugging, the calculations can go wrong.
+ dueTime = 16;
+ }
+
+ try
+ {
+ _timer!.Change(dueTime, period: Timeout.Infinite);
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _timer?.Dispose();
+ _disposed = true;
+ }
+ }
+}
diff --git a/src/Uno.UI.Composition/Composition/IFrameProvider.skia.cs b/src/Uno.UI.Composition/Composition/IFrameProvider.skia.cs
new file mode 100644
index 000000000000..dda8f2e834e4
--- /dev/null
+++ b/src/Uno.UI.Composition/Composition/IFrameProvider.skia.cs
@@ -0,0 +1,11 @@
+#nullable enable
+
+using System;
+using SkiaSharp;
+
+namespace Microsoft.UI.Composition;
+
+internal interface IFrameProvider : IDisposable
+{
+ SKImage? CurrentImage { get; }
+}
diff --git a/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs b/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs
new file mode 100644
index 000000000000..11791d256e83
--- /dev/null
+++ b/src/Uno.UI.Composition/Composition/SingleFrameProvider.skia.cs
@@ -0,0 +1,27 @@
+#nullable enable
+
+using System;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using SkiaSharp;
+
+namespace Microsoft.UI.Composition;
+
+internal sealed class SingleFrameProvider : IFrameProvider
+{
+ private SKImage? _image;
+
+ public SingleFrameProvider(SKImage image)
+ {
+ _image = image;
+ }
+
+ public SKImage? CurrentImage => _image;
+
+ public void Dispose()
+ {
+ _image?.Dispose();
+ _image = null;
+ }
+}
diff --git a/src/Uno.UI.Composition/Composition/SkiaCompositionSurface.skia.cs b/src/Uno.UI.Composition/Composition/SkiaCompositionSurface.skia.cs
index fe4d76ad1d04..74a10d7c6eda 100644
--- a/src/Uno.UI.Composition/Composition/SkiaCompositionSurface.skia.cs
+++ b/src/Uno.UI.Composition/Composition/SkiaCompositionSurface.skia.cs
@@ -1,36 +1,54 @@
#nullable enable
-
using SkiaSharp;
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
using Uno.Extensions;
using Uno.Foundation.Logging;
+using Uno.UI.Dispatching;
using Windows.Graphics;
namespace Microsoft.UI.Composition
{
internal partial class SkiaCompositionSurface : CompositionObject, ICompositionSurface
{
- // Don't use field directly. Instead, use Image property.
- private SKImage? _image;
+ // Don't set this field directly. Use SetFrameProviderAndOnFrameChanged instead.
+ private IFrameProvider? _frameProvider;
+
+ // Unused: But intentionally kept!
+ // This is here to keep the Action lifetime the same as SkiaCompositionSurface.
+ // i.e, only cause the Action to be GC'ed if SkiaCompositionSurface is GC'ed.
+ private Action? _onFrameChanged;
- public SKImage? Image
+ // Don't set directly. Use SetFrameProviderAndOnFrameChanged instead
+ private IFrameProvider? FrameProvider
{
- get => _image;
- private set
+ get => _frameProvider;
+ set
{
- _image = value;
- OnPropertyChanged(nameof(Image), isSubPropertyChange: false);
+ _frameProvider?.Dispose();
+ _frameProvider = value;
+ OnPropertyChanged(nameof(FrameProvider), isSubPropertyChange: false);
}
}
+ public SKImage? Image => FrameProvider?.CurrentImage;
+
internal SkiaCompositionSurface(SKImage image)
{
- Image = image;
+ FrameProvider = FrameProviderFactory.Create(image);
+ }
+
+ private void SetFrameProviderAndOnFrameChanged(IFrameProvider? provider, Action? onFrameChanged)
+ {
+ FrameProvider = provider;
+ _onFrameChanged = onFrameChanged;
}
internal (bool success, object nativeResult) LoadFromStream(Stream imageStream) => LoadFromStream(null, null, imageStream);
@@ -54,7 +72,7 @@ internal SkiaCompositionSurface(SKImage image)
if (result == SKCodecResult.Success)
{
- Image = SKImage.FromBitmap(bitmap);
+ SetFrameProviderAndOnFrameChanged(FrameProviderFactory.Create(SKImage.FromBitmap(bitmap)), null);
}
return (result == SKCodecResult.Success || result == SKCodecResult.IncompleteInput, result);
@@ -63,13 +81,20 @@ internal SkiaCompositionSurface(SKImage image)
{
try
{
- Image = SKImage.FromEncodedData(stream);
- return Image is null
- ? (false, "Failed to decode image")
- : (true, "Success");
+ var onFrameChanged = () => NativeDispatcher.Main.Enqueue(() => OnPropertyChanged(nameof(Image), isSubPropertyChange: false), NativeDispatcherPriority.High);
+ if (!FrameProviderFactory.TryCreate(stream, onFrameChanged, out var provider))
+ {
+ SetFrameProviderAndOnFrameChanged(null, null);
+ return (false, "Failed to decode image");
+ }
+
+ SetFrameProviderAndOnFrameChanged(provider, onFrameChanged);
+ GC.KeepAlive(onFrameChanged);
+ return (true, "Success");
}
catch (Exception e)
{
+ SetFrameProviderAndOnFrameChanged(null, null);
return (false, e.Message);
}
}
@@ -84,8 +109,13 @@ internal unsafe void CopyPixels(int pixelWidth, int pixelHeight, ReadOnlyMemory<
using (var pData = data.Pin())
{
- Image = SKImage.FromPixelCopy(info, (IntPtr)pData.Pointer, pixelWidth * 4);
+ SetFrameProviderAndOnFrameChanged(FrameProviderFactory.Create(SKImage.FromPixelCopy(info, (IntPtr)pData.Pointer, pixelWidth * 4)), null);
}
}
+
+ ~SkiaCompositionSurface()
+ {
+ SetFrameProviderAndOnFrameChanged(null, null);
+ }
}
}
diff --git a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_Image.cs b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_Image.cs
index f7a2b0857599..95af9e49cf16 100644
--- a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_Image.cs
+++ b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_Image.cs
@@ -633,6 +633,35 @@ public async Task When_Exif_Rotated_MsAppx()
await When_Exif_Rotated_Common(new Uri("ms-appx:///Assets/testimage_exif_rotated.jpg"));
}
+ [TestMethod]
+ [RunsOnUIThread]
+#if __MACOS__
+ [Ignore("Currently fails on macOS, part of #9282 epic")]
+#endif
+ public async Task When_Exif_Rotated_MsAppx_Unequal_Dimensions()
+ {
+ if (!ApiInformation.IsTypePresent("Microsoft.UI.Xaml.Media.Imaging.RenderTargetBitmap"))
+ {
+ Assert.Inconclusive(); // System.NotImplementedException: RenderTargetBitmap is not supported on this platform.;
+ }
+
+ var uri = new Uri("ms-appx:///Assets/testimage_exif_rotated_different_dimensions.jpg");
+ var image = new Image();
+ var bitmapImage = new BitmapImage(uri);
+ var imageOpened = false;
+ image.ImageOpened += (_, _) => imageOpened = true;
+ image.Source = bitmapImage;
+ WindowHelper.WindowContent = image;
+ await WindowHelper.WaitForLoaded(image);
+ await WindowHelper.WaitFor(() => imageOpened);
+ var screenshot = await TakeScreenshot(image);
+ ImageAssert.HasColorAt(screenshot, 5, screenshot.Height / 2, Color.FromArgb(0xFF, 0xED, 0x1B, 0x24), tolerance: 5);
+ ImageAssert.HasColorAt(screenshot, screenshot.Width / 2 - 10, screenshot.Height / 2, Color.FromArgb(0xFF, 0xED, 0x1B, 0x24), tolerance: 5);
+ ImageAssert.HasColorAt(screenshot, screenshot.Width / 2 + 10, screenshot.Height / 2, Color.FromArgb(0xFF, 0x23, 0xB1, 0x4D), tolerance: 5);
+ ImageAssert.HasColorAt(screenshot, screenshot.Width - 5, screenshot.Height / 2, Color.FromArgb(0xFF, 0x23, 0xB1, 0x4D), tolerance: 5);
+
+ }
+
[TestMethod]
[RunsOnUIThread]
#if __MACOS__