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__