Refactor: migrate all imaging to WinRT APIs, remove WPF

Major refactor to replace all WPF imaging (System.Windows.Media.Imaging) with Windows.Graphics.Imaging (WinRT) APIs. All image processing, encoding, and decoding now use WinRT types (BitmapDecoder, BitmapEncoder, SoftwareBitmap, etc.), enabling cross-platform support and future-proofing. Updated all batch, progress, and test logic to async/await patterns. Added CodecHelper for encoder/extension mapping and ImagingEnums for encoder options. Removed all WPF-specific code and dependencies. Updated project settings and XAML for WinRT compatibility. The codebase is now ready for WinUI or cross-platform migration.
This commit is contained in:
Yu Leng (from Dev Box)
2026-02-06 18:51:04 +08:00
parent f319fbfc07
commit 3cb71b86f3
19 changed files with 610 additions and 830 deletions

View File

@@ -11,8 +11,7 @@
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\$(AssemblyName)\</OutputPath>
<!-- Enable WPF for System.Windows.Media.Imaging support in tests -->
<UseWPF>true</UseWPF>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

View File

@@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ImageResizer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -50,29 +51,14 @@ namespace ImageResizer.Models
Assert.AreEqual("OutputDir", result.DestinationDirectory);
}
/*[Fact]
public void Process_executes_in_parallel()
{
var batch = CreateBatch(_ => Thread.Sleep(50));
batch.Files.AddRange(
Enumerable.Range(0, Environment.ProcessorCount)
.Select(i => "Image" + i + ".jpg"));
var stopwatch = Stopwatch.StartNew();
batch.Process(CancellationToken.None, (_, __) => { });
stopwatch.Stop();
Assert.InRange(stopwatch.ElapsedMilliseconds, 50, 99);
}*/
[TestMethod]
public void ProcessAggregatesErrors()
public async Task ProcessAggregatesErrors()
{
var batch = CreateBatch(file => throw new InvalidOperationException("Error: " + file));
batch.Files.Add("Image1.jpg");
batch.Files.Add("Image2.jpg");
var errors = batch.Process((_, __) => { }, CancellationToken.None).ToList();
var errors = (await batch.ProcessAsync((_, __) => { }, CancellationToken.None)).ToList();
Assert.AreEqual(2, errors.Count);
@@ -91,14 +77,14 @@ namespace ImageResizer.Models
}
[TestMethod]
public void ProcessReportsProgress()
public async Task ProcessReportsProgress()
{
var batch = CreateBatch(_ => { });
batch.Files.Add("Image1.jpg");
batch.Files.Add("Image2.jpg");
var calls = new ConcurrentBag<(int I, double Count)>();
batch.Process(
await batch.ProcessAsync(
(i, count) => calls.Add((i, count)),
CancellationToken.None);
@@ -109,8 +95,12 @@ namespace ImageResizer.Models
{
var mock = new Mock<ResizeBatch> { CallBase = true };
mock.Protected()
.Setup("Execute", ItExpr.IsAny<string>(), ItExpr.IsAny<Settings>())
.Callback((string file, Settings settings) => executeAction(file));
.Setup<Task>("ExecuteAsync", ItExpr.IsAny<string>(), ItExpr.IsAny<Settings>())
.Returns((string file, Settings settings) =>
{
executeAction(file);
return Task.CompletedTask;
});
return mock.Object;
}

View File

@@ -7,10 +7,8 @@
using System;
using System.IO;
using System.Linq;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Threading.Tasks;
using ImageResizer.Extensions;
using ImageResizer.Properties;
using ImageResizer.Test;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -20,45 +18,59 @@ namespace ImageResizer.Models
[TestClass]
public class ResizeOperationTests : IDisposable
{
// Known legacy container format GUID for PNG, used as FallbackEncoder value in settings JSON
private static readonly Guid PngContainerFormatGuid = new Guid("1b7cfaf4-713f-473c-bbcd-6137425faeaf");
private static readonly string[] DateTakenPropertyQuery = new[] { "System.Photo.DateTaken" };
private static readonly string[] CameraModelPropertyQuery = new[] { "System.Photo.CameraModel" };
private readonly TestDirectory _directory = new TestDirectory();
private bool disposedValue;
[TestMethod]
public void ExecuteCopiesFrameMetadata()
public async Task ExecuteCopiesFrameMetadata()
{
var operation = new ResizeOperation("Test.jpg", _directory, Settings());
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image => Assert.AreEqual("Test", ((BitmapMetadata)image.Frames[0].Metadata).Comment));
async decoder =>
{
var props = await decoder.BitmapProperties.GetPropertiesAsync(DateTakenPropertyQuery);
Assert.IsTrue(props.ContainsKey("System.Photo.DateTaken"), "Metadata should be preserved during transcode");
});
}
[TestMethod]
public void ExecuteCopiesFrameMetadataEvenWhenMetadataCannotBeCloned()
public async Task ExecuteCopiesFrameMetadataEvenWhenMetadataCannotBeCloned()
{
var operation = new ResizeOperation("TestMetadataIssue2447.jpg", _directory, Settings());
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image => Assert.IsNotNull(((BitmapMetadata)image.Frames[0].Metadata).CameraModel));
async decoder =>
{
var props = await decoder.BitmapProperties.GetPropertiesAsync(CameraModelPropertyQuery);
Assert.IsTrue(props.ContainsKey("System.Photo.CameraModel"), "Camera model metadata should be preserved");
});
}
[TestMethod]
public void ExecuteKeepsDateModified()
public async Task ExecuteKeepsDateModified()
{
var operation = new ResizeOperation("Test.png", _directory, Settings(s => s.KeepDateModified = true));
operation.Execute();
await operation.ExecuteAsync();
Assert.AreEqual(File.GetLastWriteTimeUtc("Test.png"), File.GetLastWriteTimeUtc(_directory.File()));
}
[TestMethod]
public void ExecuteKeepsDateModifiedWhenReplacingOriginals()
public async Task ExecuteKeepsDateModifiedWhenReplacingOriginals()
{
var path = Path.Combine(_directory, "Test.png");
File.Copy("Test.png", path);
@@ -75,55 +87,59 @@ namespace ImageResizer.Models
s.Replace = true;
}));
operation.Execute();
await operation.ExecuteAsync();
Assert.AreEqual(originalDateModified, File.GetLastWriteTimeUtc(_directory.File()));
}
[TestMethod]
public void ExecuteReplacesOriginals()
public async Task ExecuteReplacesOriginals()
{
var path = Path.Combine(_directory, "Test.png");
File.Copy("Test.png", path);
var operation = new ResizeOperation(path, null, Settings(s => s.Replace = true));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(_directory.File(), image => Assert.AreEqual(96, image.Frames[0].PixelWidth));
await AssertEx.ImageAsync(_directory.File(), decoder => Assert.AreEqual(96u, decoder.PixelWidth));
}
[TestMethod]
public void ExecuteTransformsEachFrame()
public async Task ExecuteTransformsEachFrame()
{
var operation = new ResizeOperation("Test.gif", _directory, Settings());
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
async decoder =>
{
Assert.AreEqual(2, image.Frames.Count);
AssertEx.All(image.Frames, frame => Assert.AreEqual(96, frame.PixelWidth));
Assert.AreEqual(2u, decoder.FrameCount);
for (uint i = 0; i < decoder.FrameCount; i++)
{
var frame = await decoder.GetFrameAsync(i);
Assert.AreEqual(96u, frame.PixelWidth);
}
});
}
[TestMethod]
public void ExecuteUsesFallbackEncoder()
public async Task ExecuteUsesFallbackEncoder()
{
var operation = new ResizeOperation(
"Test.ico",
_directory,
Settings(s => s.FallbackEncoder = new PngBitmapEncoder().CodecInfo.ContainerFormat));
Settings(s => s.FallbackEncoder = PngContainerFormatGuid));
operation.Execute();
await operation.ExecuteAsync();
CollectionAssert.Contains(_directory.FileNames.ToList(), "Test (Test).png");
}
[TestMethod]
public void TransformIgnoresOrientationWhenLandscapeToPortrait()
public async Task TransformIgnoresOrientationWhenLandscapeToPortrait()
{
var operation = new ResizeOperation(
"Test.png",
@@ -136,19 +152,19 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 192;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(192, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
Assert.AreEqual(192u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformIgnoresOrientationWhenPortraitToLandscape()
public async Task TransformIgnoresOrientationWhenPortraitToLandscape()
{
var operation = new ResizeOperation(
"TestPortrait.png",
@@ -161,19 +177,19 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 96;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(192, image.Frames[0].PixelHeight);
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(192u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformIgnoresIgnoreOrientationWhenAuto()
public async Task TransformIgnoresIgnoreOrientationWhenAuto()
{
var operation = new ResizeOperation(
"Test.png",
@@ -186,19 +202,19 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 0;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(48, image.Frames[0].PixelHeight);
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(48u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformIgnoresIgnoreOrientationWhenPercent()
public async Task TransformIgnoresIgnoreOrientationWhenPercent()
{
var operation = new ResizeOperation(
"Test.png",
@@ -213,19 +229,19 @@ namespace ImageResizer.Models
x.SelectedSize.Fit = ResizeFit.Stretch;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(192, image.Frames[0].PixelHeight);
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(192u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsShrinkOnly()
public async Task TransformHonorsShrinkOnly()
{
var operation = new ResizeOperation(
"Test.png",
@@ -238,19 +254,19 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 288;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(192, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
Assert.AreEqual(192u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformIgnoresShrinkOnlyWhenPercent()
public async Task TransformIgnoresShrinkOnlyWhenPercent()
{
var operation = new ResizeOperation(
"Test.png",
@@ -263,19 +279,19 @@ namespace ImageResizer.Models
x.SelectedSize.Unit = ResizeUnit.Percent;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(256, image.Frames[0].PixelWidth);
Assert.AreEqual(128, image.Frames[0].PixelHeight);
Assert.AreEqual(256u, decoder.PixelWidth);
Assert.AreEqual(128u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsShrinkOnlyWhenAutoHeight()
public async Task TransformHonorsShrinkOnlyWhenAutoHeight()
{
var operation = new ResizeOperation(
"Test.png",
@@ -288,15 +304,15 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 0;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image => Assert.AreEqual(192, image.Frames[0].PixelWidth));
decoder => Assert.AreEqual(192u, decoder.PixelWidth));
}
[TestMethod]
public void TransformHonorsShrinkOnlyWhenAutoWidth()
public async Task TransformHonorsShrinkOnlyWhenAutoWidth()
{
var operation = new ResizeOperation(
"Test.png",
@@ -309,15 +325,15 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 288;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image => Assert.AreEqual(96, image.Frames[0].PixelHeight));
decoder => Assert.AreEqual(96u, decoder.PixelHeight));
}
[TestMethod]
public void TransformHonorsUnit()
public async Task TransformHonorsUnit()
{
var operation = new ResizeOperation(
"Test.png",
@@ -330,82 +346,79 @@ namespace ImageResizer.Models
x.SelectedSize.Unit = ResizeUnit.Inch;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(_directory.File(), image => Assert.AreEqual(Math.Ceiling(image.Frames[0].DpiX), image.Frames[0].PixelWidth));
await AssertEx.ImageAsync(_directory.File(), decoder => Assert.AreEqual((uint)Math.Ceiling(decoder.DpiX), decoder.PixelWidth));
}
[TestMethod]
public void TransformHonorsFitWhenFit()
public async Task TransformHonorsFitWhenFit()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(x => x.SelectedSize.Fit = ResizeFit.Fit));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(48, image.Frames[0].PixelHeight);
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(48u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsFitWhenFill()
public async Task TransformHonorsFitWhenFill()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(x => x.SelectedSize.Fit = ResizeFit.Fill));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
async decoder =>
{
Assert.AreEqual(Colors.White, image.Frames[0].GetFirstPixel());
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
var pixel = await decoder.GetFirstPixelAsync();
Assert.AreEqual((byte)255, pixel.R, "First pixel R should be 255 (white)");
Assert.AreEqual((byte)255, pixel.G, "First pixel G should be 255 (white)");
Assert.AreEqual((byte)255, pixel.B, "First pixel B should be 255 (white)");
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsFitWhenStretch()
public async Task TransformHonorsFitWhenStretch()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(x => x.SelectedSize.Fit = ResizeFit.Stretch));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
async decoder =>
{
Assert.AreEqual(Colors.Black, image.Frames[0].GetFirstPixel());
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
var pixel = await decoder.GetFirstPixelAsync();
Assert.AreEqual((byte)0, pixel.R, "First pixel R should be 0 (black)");
Assert.AreEqual((byte)0, pixel.G, "First pixel G should be 0 (black)");
Assert.AreEqual((byte)0, pixel.B, "First pixel B should be 0 (black)");
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsFillWithShrinkOnlyWhenCropRequired()
public async Task TransformHonorsFillWithShrinkOnlyWhenCropRequired()
{
// Testing original 96x96 pixel Test.jpg cropped to 48x96 (Fill mode).
//
// ScaleX = 48/96 = 0.5
// ScaleY = 96/96 = 1.0
// Fill mode takes the max of these = 1.0.
//
// Previously, the transform logic saw the scale of 1.0 and returned the
// original dimensions. The corrected logic recognizes that a crop is
// required on one dimension and proceeds with the operation.
var operation = new ResizeOperation(
"Test.jpg",
_directory,
@@ -417,22 +430,20 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 96;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(48, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
Assert.AreEqual(48u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsFillWithShrinkOnlyWhenUpscaleAttempted()
public async Task TransformHonorsFillWithShrinkOnlyWhenUpscaleAttempted()
{
// Confirm that attempting to upscale the original image will return the
// original dimensions when Shrink Only is enabled.
var operation = new ResizeOperation(
"Test.jpg",
_directory,
@@ -444,21 +455,20 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 192;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void TransformHonorsFillWithShrinkOnlyWhenNoChangeRequired()
public async Task TransformHonorsFillWithShrinkOnlyWhenNoChangeRequired()
{
// With a scale of 1.0 on both axes, the original should be returned.
var operation = new ResizeOperation(
"Test.jpg",
_directory,
@@ -470,70 +480,70 @@ namespace ImageResizer.Models
x.SelectedSize.Height = 96;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image =>
decoder =>
{
Assert.AreEqual(96, image.Frames[0].PixelWidth);
Assert.AreEqual(96, image.Frames[0].PixelHeight);
Assert.AreEqual(96u, decoder.PixelWidth);
Assert.AreEqual(96u, decoder.PixelHeight);
});
}
[TestMethod]
public void GetDestinationPathUniquifiesOutputFilename()
public async Task GetDestinationPathUniquifiesOutputFilename()
{
File.WriteAllBytes(Path.Combine(_directory, "Test (Test).png"), Array.Empty<byte>());
var operation = new ResizeOperation("Test.png", _directory, Settings());
operation.Execute();
await operation.ExecuteAsync();
CollectionAssert.Contains(_directory.FileNames.ToList(), "Test (Test) (1).png");
}
[TestMethod]
public void GetDestinationPathUniquifiesOutputFilenameAgain()
public async Task GetDestinationPathUniquifiesOutputFilenameAgain()
{
File.WriteAllBytes(Path.Combine(_directory, "Test (Test).png"), Array.Empty<byte>());
File.WriteAllBytes(Path.Combine(_directory, "Test (Test) (1).png"), Array.Empty<byte>());
var operation = new ResizeOperation("Test.png", _directory, Settings());
operation.Execute();
await operation.ExecuteAsync();
CollectionAssert.Contains(_directory.FileNames.ToList(), "Test (Test) (2).png");
}
[TestMethod]
public void GetDestinationPathUsesFileNameFormat()
public async Task GetDestinationPathUsesFileNameFormat()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(s => s.FileName = "%1_%2_%3_%4_%5_%6"));
operation.Execute();
await operation.ExecuteAsync();
CollectionAssert.Contains(_directory.FileNames.ToList(), "Test_Test_96_96_96_48.png");
}
[TestMethod]
public void ExecuteHandlesDirectoriesInFileNameFormat()
public async Task ExecuteHandlesDirectoriesInFileNameFormat()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(s => s.FileName = @"Directory\%1 (%2)"));
operation.Execute();
await operation.ExecuteAsync();
Assert.IsTrue(File.Exists(_directory + @"\Directory\Test (Test).png"));
}
[TestMethod]
public void StripMetadata()
public async Task StripMetadata()
{
var operation = new ResizeOperation(
"TestMetadataIssue1928.jpg",
@@ -544,18 +554,26 @@ namespace ImageResizer.Models
x.RemoveMetadata = true;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image => Assert.IsNull(((BitmapMetadata)image.Frames[0].Metadata).DateTaken));
AssertEx.Image(
_directory.File(),
image => Assert.IsNotNull(((BitmapMetadata)image.Frames[0].Metadata).GetQuerySafe("System.Photo.Orientation")));
async decoder =>
{
try
{
var props = await decoder.BitmapProperties.GetPropertiesAsync(DateTakenPropertyQuery);
Assert.IsFalse(props.ContainsKey("System.Photo.DateTaken"), "DateTaken should be stripped");
}
catch (Exception)
{
// If GetPropertiesAsync throws, metadata is not present — which is expected
}
});
}
[TestMethod]
public void StripMetadataWhenNoMetadataPresent()
public async Task StripMetadataWhenNoMetadataPresent()
{
var operation = new ResizeOperation(
"TestMetadataIssue1928_NoMetadata.jpg",
@@ -566,18 +584,26 @@ namespace ImageResizer.Models
x.RemoveMetadata = true;
}));
operation.Execute();
await operation.ExecuteAsync();
AssertEx.Image(
await AssertEx.ImageAsync(
_directory.File(),
image => Assert.IsNull(((BitmapMetadata)image.Frames[0].Metadata).DateTaken));
AssertEx.Image(
_directory.File(),
image => Assert.IsNull(((BitmapMetadata)image.Frames[0].Metadata).GetQuerySafe("System.Photo.Orientation")));
async decoder =>
{
try
{
var props = await decoder.BitmapProperties.GetPropertiesAsync(DateTakenPropertyQuery);
Assert.IsFalse(props.ContainsKey("System.Photo.DateTaken"), "DateTaken should not exist");
}
catch (Exception)
{
// Expected: no metadata block at all
}
});
}
[TestMethod]
public void VerifyFileNameIsSanitized()
public async Task VerifyFileNameIsSanitized()
{
var operation = new ResizeOperation(
"Test.png",
@@ -589,13 +615,13 @@ namespace ImageResizer.Models
s.SelectedSize.Name = "Test\\/";
}));
operation.Execute();
await operation.ExecuteAsync();
Assert.IsTrue(File.Exists(_directory + @"\Directory\Test_______(Test__).png"));
}
[TestMethod]
public void VerifyNotRecommendedNameIsChanged()
public async Task VerifyNotRecommendedNameIsChanged()
{
var operation = new ResizeOperation(
"Test.png",
@@ -606,7 +632,7 @@ namespace ImageResizer.Models
s.FileName = @"nul";
}));
operation.Execute();
await operation.ExecuteAsync();
Assert.IsTrue(File.Exists(_directory + @"\nul_.png"));
}

View File

@@ -8,11 +8,14 @@ using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.IO.Abstractions;
using System.Windows.Media.Imaging;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Graphics.Imaging;
[module: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1636:FileHeaderCopyrightTextMustMatch", Justification = "File created under PowerToys.")]
namespace ImageResizer.Test
@@ -29,17 +32,20 @@ namespace ImageResizer.Test
}
}
public static void Image(string path, Action<BitmapDecoder> action)
public static async Task ImageAsync(string path, Action<BitmapDecoder> action)
{
using (var stream = _fileSystem.File.OpenRead(path))
{
var image = BitmapDecoder.Create(
stream,
BitmapCreateOptions.PreservePixelFormat,
BitmapCacheOption.None);
using var stream = _fileSystem.File.OpenRead(path);
var winrtStream = stream.AsRandomAccessStream();
var decoder = await BitmapDecoder.CreateAsync(winrtStream);
action(decoder);
}
action(image);
}
public static async Task ImageAsync(string path, Func<BitmapDecoder, Task> action)
{
using var stream = _fileSystem.File.OpenRead(path);
var winrtStream = stream.AsRandomAccessStream();
var decoder = await BitmapDecoder.CreateAsync(winrtStream);
await action(decoder);
}
public static RaisedEvent<NotifyCollectionChangedEventArgs> Raises<T>(

View File

@@ -1,28 +1,34 @@
#pragma warning disable IDE0073
#pragma warning disable IDE0073
// Copyright (c) Brice Lambson
// The Brice Lambson licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. Code forked from Brice Lambson's https://github.com/bricelam/ImageResizer/
#pragma warning restore IDE0073
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System;
using System.Threading.Tasks;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
namespace ImageResizer.Test
{
internal static class BitmapSourceExtensions
{
public static Color GetFirstPixel(this BitmapSource source)
public static async Task<(byte R, byte G, byte B, byte A)> GetFirstPixelAsync(this BitmapDecoder decoder)
{
var pixel = new byte[4];
new FormatConvertedBitmap(
new CroppedBitmap(source, new Int32Rect(0, 0, 1, 1)),
PixelFormats.Bgra32,
destinationPalette: null,
alphaThreshold: 0)
.CopyPixels(pixel, 4, 0);
using var softwareBitmap = await decoder.GetSoftwareBitmapAsync(
BitmapPixelFormat.Bgra8,
BitmapAlphaMode.Premultiplied);
return Color.FromArgb(pixel[3], pixel[2], pixel[1], pixel[0]);
var buffer = new Windows.Storage.Streams.Buffer((uint)(softwareBitmap.PixelWidth * softwareBitmap.PixelHeight * 4));
softwareBitmap.CopyToBuffer(buffer);
using var reader = DataReader.FromBuffer(buffer);
byte b = reader.ReadByte();
byte g = reader.ReadByte();
byte r = reader.ReadByte();
byte a = reader.ReadByte();
return (r, g, b, a);
}
}
}

View File

@@ -6,6 +6,7 @@ using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ImageResizer.Models;
using ImageResizer.Properties;
@@ -58,10 +59,10 @@ namespace ImageResizer.Cli
return 1;
}
return RunSilentMode(cliOptions);
return RunSilentModeAsync(cliOptions).GetAwaiter().GetResult();
}
private int RunSilentMode(CliOptions cliOptions)
private async Task<int> RunSilentModeAsync(CliOptions cliOptions)
{
var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions);
var settings = Settings.Default;
@@ -73,7 +74,7 @@ namespace ImageResizer.Cli
bool useLineBasedProgress = cliOptions.ProgressLines ?? false;
int lastReportedMilestone = -1;
var errors = batch.Process(
var errors = await batch.ProcessAsync(
(completed, total) =>
{
var progress = (int)((completed / total) * 100);

View File

@@ -1,25 +0,0 @@
#pragma warning disable IDE0073
// Copyright (c) Brice Lambson
// The Brice Lambson licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. Code forked from Brice Lambson's https://github.com/bricelam/ImageResizer/
#pragma warning restore IDE0073
namespace System.Windows.Media.Imaging
{
internal static class BitmapEncoderExtensions
{
public static bool CanEncode(this BitmapEncoder encoder)
{
try
{
var temp = encoder.CodecInfo;
}
catch (NotSupportedException)
{
return false;
}
return true;
}
}
}

View File

@@ -1,258 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Windows.Media.Imaging;
namespace ImageResizer.Extensions
{
internal static class BitmapMetadataExtension
{
public static void CopyMetadataPropertyTo(this BitmapMetadata source, BitmapMetadata target, string query)
{
if (source == null || target == null || string.IsNullOrWhiteSpace(query))
{
return;
}
try
{
var value = source.GetQuerySafe(query);
if (value == null)
{
return;
}
target.SetQuery(query, value);
}
catch (InvalidOperationException)
{
// InvalidOperationException is thrown if metadata object is in readonly state.
return;
}
}
public static object GetQuerySafe(this BitmapMetadata metadata, string query)
{
if (metadata == null || string.IsNullOrWhiteSpace(query))
{
return null;
}
try
{
if (metadata.ContainsQuery(query))
{
return metadata.GetQuery(query);
}
else
{
return null;
}
}
catch (NotSupportedException)
{
// NotSupportedException is throw if the metadata entry is not preset on the target image (e.g. Orientation not set).
return null;
}
}
public static void RemoveQuerySafe(this BitmapMetadata metadata, string query)
{
if (metadata == null || string.IsNullOrWhiteSpace(query))
{
return;
}
try
{
if (metadata.ContainsQuery(query))
{
metadata.RemoveQuery(query);
}
}
catch (Exception ex)
{
Debug.WriteLine($"Exception while trying to remove metadata entry at position: {query}");
Debug.WriteLine(ex);
}
}
public static void SetQuerySafe(this BitmapMetadata metadata, string query, object value)
{
if (metadata == null || string.IsNullOrWhiteSpace(query) || value == null)
{
return;
}
try
{
metadata.SetQuery(query, value);
}
catch (Exception ex)
{
Debug.WriteLine($"Exception while trying to set metadata {value} at position: {query}");
Debug.WriteLine(ex);
}
}
/// <summary>
/// Gets all metadata.
/// Iterates recursively through metadata and adds valid items to a list while skipping invalid data items.
/// </summary>
/// <remarks>
/// Invalid data items are items which throw an exception when reading the data with metadata.GetQuery(...).
/// Sometimes Metadata collections are improper closed and cause an exception on IEnumerator.MoveNext(). In this case, we return all data items which were successfully collected so far.
/// </remarks>
/// <returns>
/// metadata path and metadata value of all successfully read data items.
/// </returns>
public static List<(string MetadataPath, object Value)> GetListOfMetadata(this BitmapMetadata metadata)
{
var listOfAllMetadata = new List<(string MetadataPath, object Value)>();
try
{
GetMetadataRecursively(metadata, string.Empty);
}
catch (Exception ex)
{
Debug.WriteLine($"Exception while trying to iterate recursively over metadata. We were able to read {listOfAllMetadata.Count} metadata entries.");
Debug.WriteLine(ex);
}
return listOfAllMetadata;
void GetMetadataRecursively(BitmapMetadata metadata, string query)
{
foreach (string relativeQuery in metadata)
{
string absolutePath = query + relativeQuery;
object metadataQueryReader = null;
try
{
metadataQueryReader = GetQueryWithPreCheck(metadata, relativeQuery);
}
catch (Exception ex)
{
Debug.WriteLine($"Removing corrupt metadata property {absolutePath}. Skipping metadata entry | {ex.Message}");
Debug.WriteLine(ex);
}
if (metadataQueryReader != null)
{
listOfAllMetadata.Add((absolutePath, metadataQueryReader));
}
else
{
Debug.WriteLine($"No metadata found for query {absolutePath}. Skipping empty null entry because its invalid.");
}
if (metadataQueryReader is BitmapMetadata innerMetadata)
{
GetMetadataRecursively(innerMetadata, absolutePath);
}
}
}
object GetQueryWithPreCheck(BitmapMetadata metadata, string query)
{
if (metadata == null || string.IsNullOrWhiteSpace(query))
{
return null;
}
if (metadata.ContainsQuery(query))
{
return metadata.GetQuery(query);
}
else
{
return null;
}
}
}
/// <summary>
/// Prints all metadata to debug console
/// </summary>
/// <remarks>
/// Intended for debug only!!!
/// </remarks>
public static void PrintsAllMetadataToDebugOutput(this BitmapMetadata metadata)
{
if (metadata == null)
{
Debug.WriteLine($"Metadata was null.");
}
var listOfMetadata = metadata.GetListOfMetadataForDebug();
foreach (var metadataItem in listOfMetadata)
{
// Debug.WriteLine($"modifiableMetadata.RemoveQuerySafe(\"{metadataItem.metadataPath}\");");
Debug.WriteLine($"{metadataItem.MetadataPath} | {metadataItem.Value}");
}
}
/// <summary>
/// Gets all metadata
/// Iterates recursively through all metadata
/// </summary>
/// <remarks>
/// Intended for debug only!!!
/// </remarks>
public static List<(string MetadataPath, object Value)> GetListOfMetadataForDebug(this BitmapMetadata metadata)
{
var listOfAllMetadata = new List<(string MetadataPath, object Value)>();
try
{
GetMetadataRecursively(metadata, string.Empty);
}
catch (Exception ex)
{
Debug.WriteLine($"Exception while trying to iterate recursively over metadata. We were able to read {listOfAllMetadata.Count} metadata entries.");
Debug.WriteLine(ex);
}
return listOfAllMetadata;
void GetMetadataRecursively(BitmapMetadata metadata, string query)
{
if (metadata == null)
{
return;
}
foreach (string relativeQuery in metadata)
{
string absolutePath = query + relativeQuery;
object metadataQueryReader = null;
try
{
metadataQueryReader = metadata.GetQuerySafe(relativeQuery);
listOfAllMetadata.Add((absolutePath, metadataQueryReader));
}
catch (Exception ex)
{
listOfAllMetadata.Add((absolutePath, $"######## INVALID METADATA: {ex.Message}"));
Debug.WriteLine(ex);
}
if (metadataQueryReader is BitmapMetadata innerMetadata)
{
GetMetadataRecursively(innerMetadata, absolutePath);
}
}
}
}
}
}

View File

@@ -121,7 +121,7 @@
</Button>
<StackPanel Margin="0,8,0,0" Visibility="{Binding IsModelDownloading, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<!-- ProgressRing commented out for WPF compatibility -->
<!-- TODO: Add ProgressRing here -->
<TextBlock
Margin="0,8,0,0"
HorizontalAlignment="Center"

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace ImageResizer.Models
{
/// <summary>
/// PNG interlace option for the encoder.
/// Integer values preserve backward compatibility with existing settings JSON.
/// </summary>
public enum PngInterlaceOption
{
Default = 0,
On = 1,
Off = 2,
}
/// <summary>
/// TIFF compression option for the encoder.
/// Integer values preserve backward compatibility with existing settings JSON.
/// </summary>
public enum TiffCompressOption
{
Default = 0,
None = 1,
Ccitt3 = 2,
Ccitt4 = 3,
Lzw = 4,
Rle = 5,
Zip = 6,
}
}

View File

@@ -140,33 +140,31 @@ namespace ImageResizer.Models
return FromCliOptions(standardInput, options);
}
public IEnumerable<ResizeError> Process(Action<int, double> reportProgress, CancellationToken cancellationToken)
public Task<IEnumerable<ResizeError>> ProcessAsync(Action<int, double> reportProgress, CancellationToken cancellationToken)
{
// NOTE: Settings.Default is captured once before parallel processing.
// Any changes to settings on disk during this batch will NOT be reflected until the next batch.
// This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch.
return Process(reportProgress, Settings.Default, cancellationToken);
return ProcessAsync(reportProgress, Settings.Default, cancellationToken);
}
public IEnumerable<ResizeError> Process(Action<int, double> reportProgress, Settings settings, CancellationToken cancellationToken)
public async Task<IEnumerable<ResizeError>> ProcessAsync(Action<int, double> reportProgress, Settings settings, CancellationToken cancellationToken)
{
double total = Files.Count;
int completed = 0;
var errors = new ConcurrentBag<ResizeError>();
// TODO: If we ever switch to Windows.Graphics.Imaging, we can get a lot more throughput by using the async
// APIs and a custom SynchronizationContext
Parallel.ForEach(
await Parallel.ForEachAsync(
Files,
new ParallelOptions
{
CancellationToken = cancellationToken,
},
(file, state, i) =>
async (file, ct) =>
{
try
{
Execute(file, settings);
await ExecuteAsync(file, settings);
}
catch (Exception ex)
{
@@ -180,10 +178,10 @@ namespace ImageResizer.Models
return errors;
}
protected virtual void Execute(string file, Settings settings)
protected virtual async Task ExecuteAsync(string file, Settings settings)
{
var aiService = _aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
new ResizeOperation(file, DestinationDirectory, settings, aiService).Execute();
await new ResizeOperation(file, DestinationDirectory, settings, aiService).ExecuteAsync();
}
}
}

View File

@@ -5,16 +5,15 @@
#pragma warning restore IDE0073
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ImageResizer.Extensions;
using System.Threading.Tasks;
using Windows.Foundation;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
using ImageResizer.Helpers;
using ImageResizer.Properties;
using ImageResizer.Services;
@@ -65,78 +64,134 @@ namespace ImageResizer.Models
_aiSuperResolutionService = aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
}
public void Execute()
public async Task ExecuteAsync()
{
string path;
using (var inputStream = _fileSystem.File.OpenRead(_file))
{
var decoder = BitmapDecoder.Create(
inputStream,
BitmapCreateOptions.PreservePixelFormat,
BitmapCacheOption.None);
var winrtInputStream = inputStream.AsRandomAccessStream();
var decoder = await BitmapDecoder.CreateAsync(winrtInputStream);
var containerFormat = decoder.CodecInfo.ContainerFormat;
var encoder = CreateEncoder(containerFormat);
if (decoder.Metadata != null)
// Determine encoder ID from decoder
var encoderId = CodecHelper.GetEncoderIdForDecoder(decoder);
if (encoderId == null || !CodecHelper.CanEncode(encoderId.Value))
{
try
{
encoder.Metadata = decoder.Metadata;
}
catch (InvalidOperationException)
{
}
encoderId = CodecHelper.GetEncoderIdFromLegacyGuid(_settings.FallbackEncoder);
}
if (decoder.Palette != null)
var encoderGuid = encoderId.Value;
if (_settings.SelectedSize is AiSize)
{
encoder.Palette = decoder.Palette;
// AI super resolution path
path = await ExecuteAiAsync(decoder, winrtInputStream, encoderGuid);
}
foreach (var originalFrame in decoder.Frames)
else
{
var transformedBitmap = Transform(originalFrame);
// Standard resize path
var originalWidth = (int)decoder.PixelWidth;
var originalHeight = (int)decoder.PixelHeight;
var dpiX = decoder.DpiX;
var dpiY = decoder.DpiY;
// if the frame was not modified, we should not replace the metadata
if (transformedBitmap == originalFrame)
var (scaledWidth, scaledHeight, cropBounds, noTransformNeeded) =
CalculateDimensions(originalWidth, originalHeight, dpiX, dpiY);
// For destination path, calculate final output dimensions
int outputWidth, outputHeight;
if (noTransformNeeded)
{
encoder.Frames.Add(originalFrame);
outputWidth = originalWidth;
outputHeight = originalHeight;
}
else if (cropBounds.HasValue)
{
outputWidth = (int)cropBounds.Value.Width;
outputHeight = (int)cropBounds.Value.Height;
}
else
{
BitmapMetadata originalMetadata = (BitmapMetadata)originalFrame.Metadata;
#if DEBUG
Debug.WriteLine($"### Processing metadata of file {_file}");
originalMetadata.PrintsAllMetadataToDebugOutput();
#endif
var metadata = GetValidMetadata(originalMetadata, transformedBitmap, containerFormat);
if (_settings.RemoveMetadata && metadata != null)
{
// strip any metadata that doesn't affect rendering
var newMetadata = new BitmapMetadata(metadata.Format);
metadata.CopyMetadataPropertyTo(newMetadata, "System.Photo.Orientation");
metadata.CopyMetadataPropertyTo(newMetadata, "System.Image.ColorSpace");
metadata = newMetadata;
}
var frame = CreateBitmapFrame(transformedBitmap, metadata);
encoder.Frames.Add(frame);
outputWidth = (int)scaledWidth;
outputHeight = (int)scaledHeight;
}
}
path = GetDestinationPath(encoder);
_fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(path));
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.Write))
{
encoder.Save(outputStream);
path = GetDestinationPath(encoderGuid, outputWidth, outputHeight);
_fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(path));
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.Write))
{
var winrtOutputStream = outputStream.AsRandomAccessStream();
if (!_settings.RemoveMetadata)
{
// Transcode path: preserves all metadata automatically
winrtInputStream.Seek(0);
var encoder = await BitmapEncoder.CreateForTranscodingAsync(winrtOutputStream, decoder);
if (!noTransformNeeded)
{
encoder.BitmapTransform.ScaledWidth = scaledWidth;
encoder.BitmapTransform.ScaledHeight = scaledHeight;
encoder.BitmapTransform.InterpolationMode = BitmapInterpolationMode.Fant;
if (cropBounds.HasValue)
{
encoder.BitmapTransform.Bounds = cropBounds.Value;
}
}
await ConfigureEncoderPropertiesAsync(encoder, encoderGuid);
await encoder.FlushAsync();
}
else
{
// Strip metadata path: fresh encoder = no old metadata
var propertySet = GetEncoderPropertySet(encoderGuid);
var encoder = propertySet != null
? await BitmapEncoder.CreateAsync(encoderGuid, winrtOutputStream, propertySet)
: await BitmapEncoder.CreateAsync(encoderGuid, winrtOutputStream);
var transform = new BitmapTransform();
if (!noTransformNeeded)
{
transform.ScaledWidth = scaledWidth;
transform.ScaledHeight = scaledHeight;
transform.InterpolationMode = BitmapInterpolationMode.Fant;
if (cropBounds.HasValue)
{
transform.Bounds = cropBounds.Value;
}
}
else
{
transform.ScaledWidth = (uint)originalWidth;
transform.ScaledHeight = (uint)originalHeight;
}
// Handle multi-frame images (e.g., GIF)
for (uint i = 0; i < decoder.FrameCount; i++)
{
if (i > 0)
{
await encoder.GoToNextFrameAsync();
}
var frame = await decoder.GetFrameAsync(i);
var bitmap = await frame.GetSoftwareBitmapAsync(
frame.BitmapPixelFormat,
BitmapAlphaMode.Premultiplied,
transform,
ExifOrientationMode.IgnoreExifOrientation,
ColorManagementMode.DoNotColorManage);
encoder.SetSoftwareBitmap(bitmap);
}
await encoder.FlushAsync();
}
}
}
}
@@ -153,50 +208,72 @@ namespace ImageResizer.Models
}
}
private BitmapEncoder CreateEncoder(Guid containerFormat)
private async Task<string> ExecuteAiAsync(BitmapDecoder decoder, IRandomAccessStream winrtInputStream, Guid encoderGuid)
{
var createdEncoder = BitmapEncoder.Create(containerFormat);
if (!createdEncoder.CanEncode())
try
{
createdEncoder = BitmapEncoder.Create(_settings.FallbackEncoder);
}
// Get the source bitmap for AI processing
var softwareBitmap = await decoder.GetSoftwareBitmapAsync(
BitmapPixelFormat.Bgra8,
BitmapAlphaMode.Premultiplied);
ConfigureEncoder(createdEncoder);
var aiResult = _aiSuperResolutionService.ApplySuperResolution(
softwareBitmap,
_settings.AiSize.Scale,
_file);
return createdEncoder;
void ConfigureEncoder(BitmapEncoder encoder)
{
switch (encoder)
if (aiResult == null)
{
case JpegBitmapEncoder jpegEncoder:
jpegEncoder.QualityLevel = MathHelpers.Clamp(_settings.JpegQualityLevel, 1, 100);
break;
case PngBitmapEncoder pngBitmapEncoder:
pngBitmapEncoder.Interlace = _settings.PngInterlaceOption;
break;
case TiffBitmapEncoder tiffEncoder:
tiffEncoder.Compression = _settings.TiffCompressOption;
break;
throw new InvalidOperationException(ResourceLoaderInstance.ResourceLoader.GetString("Error_AiConversionFailed"));
}
var outputWidth = aiResult.PixelWidth;
var outputHeight = aiResult.PixelHeight;
var path = GetDestinationPath(encoderGuid, outputWidth, outputHeight);
_fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(path));
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.Write))
{
var winrtOutputStream = outputStream.AsRandomAccessStream();
if (!_settings.RemoveMetadata)
{
// Transcode path: preserves metadata
winrtInputStream.Seek(0);
var encoder = await BitmapEncoder.CreateForTranscodingAsync(winrtOutputStream, decoder);
encoder.SetSoftwareBitmap(aiResult);
await ConfigureEncoderPropertiesAsync(encoder, encoderGuid);
await encoder.FlushAsync();
}
else
{
// Strip metadata path
var propertySet = GetEncoderPropertySet(encoderGuid);
var encoder = propertySet != null
? await BitmapEncoder.CreateAsync(encoderGuid, winrtOutputStream, propertySet)
: await BitmapEncoder.CreateAsync(encoderGuid, winrtOutputStream);
encoder.SetSoftwareBitmap(aiResult);
await encoder.FlushAsync();
}
}
return path;
}
catch (Exception ex) when (ex is not InvalidOperationException)
{
var errorMessage = string.Format(CultureInfo.CurrentCulture, AiErrorFormat, ex.Message);
throw new InvalidOperationException(errorMessage, ex);
}
}
private BitmapSource Transform(BitmapSource source)
private (uint ScaledWidth, uint ScaledHeight, BitmapBounds? CropBounds, bool NoTransformNeeded) CalculateDimensions(
int originalWidth, int originalHeight, double dpiX, double dpiY)
{
if (_settings.SelectedSize is AiSize)
{
return TransformWithAi(source);
}
int originalWidth = source.PixelWidth;
int originalHeight = source.PixelHeight;
// Convert from the chosen size unit to pixels, if necessary.
double width = _settings.SelectedSize.GetPixelWidth(originalWidth, source.DpiX);
double height = _settings.SelectedSize.GetPixelHeight(originalHeight, source.DpiY);
double width = _settings.SelectedSize.GetPixelWidth(originalWidth, dpiX);
double height = _settings.SelectedSize.GetPixelHeight(originalHeight, dpiY);
// Swap target width/height dimensions if orientation correction is required.
bool canSwapDimensions = _settings.IgnoreOrientation &&
@@ -238,7 +315,7 @@ namespace ImageResizer.Models
{
if (scaleX > 1 || scaleY > 1)
{
return source;
return ((uint)originalWidth, (uint)originalHeight, null, true);
}
bool isFillCropRequired = _settings.SelectedSize.Fit == ResizeFit.Fill &&
@@ -246,140 +323,95 @@ namespace ImageResizer.Models
if (scaleX == 1 && scaleY == 1 && !isFillCropRequired)
{
return source;
return ((uint)originalWidth, (uint)originalHeight, null, true);
}
}
// Apply the scaling.
var scaledBitmap = new TransformedBitmap(source, new ScaleTransform(scaleX, scaleY));
// Calculate scaled dimensions
uint scaledWidth = (uint)Math.Max(1, (int)Math.Round(originalWidth * scaleX));
uint scaledHeight = (uint)Math.Max(1, (int)Math.Round(originalHeight * scaleY));
// Apply the centered crop for Fill mode, if necessary.
if (_settings.SelectedSize.Fit == ResizeFit.Fill
&& (scaledBitmap.PixelWidth > width
|| scaledBitmap.PixelHeight > height))
&& (scaledWidth > (uint)width || scaledHeight > (uint)height))
{
int x = (int)(((originalWidth * scaleX) - width) / 2);
int y = (int)(((originalHeight * scaleY) - height) / 2);
uint cropX = (uint)(((originalWidth * scaleX) - width) / 2);
uint cropY = (uint)(((originalHeight * scaleY) - height) / 2);
return new CroppedBitmap(scaledBitmap, new Int32Rect(x, y, (int)width, (int)height));
}
return scaledBitmap;
}
private BitmapSource TransformWithAi(BitmapSource source)
{
try
{
var result = _aiSuperResolutionService.ApplySuperResolution(
source,
_settings.AiSize.Scale,
_file);
if (result == null)
var cropBounds = new BitmapBounds
{
throw new InvalidOperationException(ResourceLoaderInstance.ResourceLoader.GetString("Error_AiConversionFailed"));
}
X = cropX,
Y = cropY,
Width = (uint)width,
Height = (uint)height,
};
return result;
return (scaledWidth, scaledHeight, cropBounds, false);
}
catch (Exception ex)
return (scaledWidth, scaledHeight, null, false);
}
private async Task ConfigureEncoderPropertiesAsync(BitmapEncoder encoder, Guid encoderGuid)
{
if (encoderGuid == BitmapEncoder.JpegEncoderId)
{
var errorMessage = string.Format(CultureInfo.CurrentCulture, AiErrorFormat, ex.Message);
throw new InvalidOperationException(errorMessage, ex);
await encoder.BitmapProperties.SetPropertiesAsync(new BitmapPropertySet
{
{ "ImageQuality", new BitmapTypedValue((float)MathHelpers.Clamp(_settings.JpegQualityLevel, 1, 100) / 100f, PropertyType.Single) },
});
}
}
private BitmapMetadata GetValidMetadata(BitmapMetadata originalMetadata, BitmapSource transformedBitmap, Guid containerFormat)
private BitmapPropertySet GetEncoderPropertySet(Guid encoderGuid)
{
if (originalMetadata == null)
if (encoderGuid == BitmapEncoder.JpegEncoderId)
{
return null;
return new BitmapPropertySet
{
{ "ImageQuality", new BitmapTypedValue((float)MathHelpers.Clamp(_settings.JpegQualityLevel, 1, 100) / 100f, PropertyType.Single) },
};
}
var frameWithOriginalMetadata = CreateBitmapFrame(transformedBitmap, originalMetadata);
if (EnsureFrameIsValid(frameWithOriginalMetadata))
if (encoderGuid == BitmapEncoder.TiffEncoderId)
{
return originalMetadata;
}
var recreatedMetadata = BuildMetadataFromTheScratch(originalMetadata);
var frameWithRecreatedMetadata = CreateBitmapFrame(transformedBitmap, recreatedMetadata);
if (EnsureFrameIsValid(frameWithRecreatedMetadata))
{
return recreatedMetadata;
var compressionMethod = MapTiffCompression(_settings.TiffCompressOption);
if (compressionMethod.HasValue)
{
return new BitmapPropertySet
{
{ "TiffCompressionMethod", new BitmapTypedValue(compressionMethod.Value, PropertyType.UInt8) },
};
}
}
return null;
bool EnsureFrameIsValid(BitmapFrame frameToBeChecked)
{
try
{
var encoder = CreateEncoder(containerFormat);
encoder.Frames.Add(frameToBeChecked);
using (var testStream = new MemoryStream())
{
encoder.Save(testStream);
}
return true;
}
catch (Exception)
{
return false;
}
}
}
private static BitmapMetadata BuildMetadataFromTheScratch(BitmapMetadata originalMetadata)
private static byte? MapTiffCompression(TiffCompressOption option)
{
try
return option switch
{
var metadata = new BitmapMetadata(originalMetadata.Format);
var listOfMetadata = originalMetadata.GetListOfMetadata();
foreach (var (metadataPath, value) in listOfMetadata)
{
if (value is BitmapMetadata bitmapMetadata)
{
var innerMetadata = new BitmapMetadata(bitmapMetadata.Format);
metadata.SetQuerySafe(metadataPath, innerMetadata);
}
else
{
metadata.SetQuerySafe(metadataPath, value);
}
}
return metadata;
}
catch (ArgumentException ex)
{
Debug.WriteLine(ex);
return null;
}
TiffCompressOption.None => 1,
TiffCompressOption.Ccitt3 => 2,
TiffCompressOption.Ccitt4 => 3,
TiffCompressOption.Lzw => 4,
TiffCompressOption.Rle => 5,
TiffCompressOption.Zip => 6,
_ => null, // Default: let the encoder decide
};
}
private static BitmapFrame CreateBitmapFrame(BitmapSource transformedBitmap, BitmapMetadata metadata)
{
return BitmapFrame.Create(
transformedBitmap,
thumbnail: null,
metadata,
colorContexts: null);
}
private string GetDestinationPath(BitmapEncoder encoder)
private string GetDestinationPath(Guid encoderGuid, int outputPixelWidth, int outputPixelHeight)
{
var directory = _destinationDirectory ?? _fileSystem.Path.GetDirectoryName(_file);
var originalFileName = _fileSystem.Path.GetFileNameWithoutExtension(_file);
var supportedExtensions = encoder.CodecInfo.FileExtensions.Split(',');
var supportedExtensions = CodecHelper.GetSupportedExtensions(encoderGuid);
var extension = _fileSystem.Path.GetExtension(_file);
if (!supportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
extension = supportedExtensions.FirstOrDefault();
extension = CodecHelper.GetDefaultExtension(encoderGuid);
}
string sizeName = _settings.SelectedSize is AiSize aiSize
@@ -389,8 +421,8 @@ namespace ImageResizer.Models
.Replace('\\', '_')
.Replace('/', '_');
var selectedWidth = _settings.SelectedSize is AiSize ? encoder.Frames[0].PixelWidth : _settings.SelectedSize.Width;
var selectedHeight = _settings.SelectedSize is AiSize ? encoder.Frames[0].PixelHeight : _settings.SelectedSize.Height;
var selectedWidth = _settings.SelectedSize is AiSize ? outputPixelWidth : _settings.SelectedSize.Width;
var selectedHeight = _settings.SelectedSize is AiSize ? outputPixelHeight : _settings.SelectedSize.Height;
var fileName = string.Format(
CultureInfo.CurrentCulture,
_settings.FileNameFormat,
@@ -398,8 +430,8 @@ namespace ImageResizer.Models
sizeNameSanitized,
selectedWidth,
selectedHeight,
encoder.Frames[0].PixelWidth,
encoder.Frames[0].PixelHeight);
outputPixelWidth,
outputPixelHeight);
fileName = fileName
.Replace(':', '_')

View File

@@ -17,7 +17,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Windows.Media.Imaging;
using ImageResizer.Helpers;
using ImageResizer.Models;
using ManagedCommon;
@@ -89,8 +88,8 @@ namespace ImageResizer.Properties
IgnoreOrientation = true;
RemoveMetadata = false;
JpegQualityLevel = 90;
PngInterlaceOption = System.Windows.Media.Imaging.PngInterlaceOption.Default;
TiffCompressOption = System.Windows.Media.Imaging.TiffCompressOption.Default;
PngInterlaceOption = Models.PngInterlaceOption.Default;
TiffCompressOption = Models.TiffCompressOption.Default;
FileName = "%1 (%2)";
Sizes = new ObservableCollection<ResizeSize>
{

View File

@@ -3,12 +3,12 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Windows.Media.Imaging;
using Windows.Graphics.Imaging;
namespace ImageResizer.Services
{
public interface IAISuperResolutionService : IDisposable
{
BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath);
SoftwareBitmap ApplySuperResolution(SoftwareBitmap source, int scale, string filePath);
}
}

View File

@@ -2,7 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows.Media.Imaging;
using Windows.Graphics.Imaging;
namespace ImageResizer.Services
{
@@ -14,7 +14,7 @@ namespace ImageResizer.Services
{
}
public BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath)
public SoftwareBitmap ApplySuperResolution(SoftwareBitmap source, int scale, string filePath)
{
return source;
}

View File

@@ -3,13 +3,8 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Microsoft.Windows.AI;
using Microsoft.Windows.AI.Imaging;
using Windows.Graphics.Imaging;
@@ -91,7 +86,7 @@ namespace ImageResizer.Services
}
}
public BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath)
public SoftwareBitmap ApplySuperResolution(SoftwareBitmap source, int scale, string filePath)
{
if (source == null || _disposed)
{
@@ -102,19 +97,12 @@ namespace ImageResizer.Services
// Currently not used by the ImageScaler API
try
{
// Convert WPF BitmapSource to WinRT SoftwareBitmap
var softwareBitmap = ConvertBitmapSourceToSoftwareBitmap(source);
if (softwareBitmap == null)
{
return source;
}
// Calculate target dimensions
var newWidth = softwareBitmap.PixelWidth * scale;
var newHeight = softwareBitmap.PixelHeight * scale;
var newWidth = source.PixelWidth * scale;
var newHeight = source.PixelHeight * scale;
// Apply super resolution with thread-safe access
// _usageLock protects concurrent access from Parallel.ForEach threads
// _usageLock protects concurrent access from Parallel.ForEachAsync threads
SoftwareBitmap scaledBitmap;
lock (_usageLock)
{
@@ -123,16 +111,10 @@ namespace ImageResizer.Services
return source;
}
scaledBitmap = _imageScaler.ScaleSoftwareBitmap(softwareBitmap, newWidth, newHeight);
scaledBitmap = _imageScaler.ScaleSoftwareBitmap(source, newWidth, newHeight);
}
if (scaledBitmap == null)
{
return source;
}
// Convert back to WPF BitmapSource
return ConvertSoftwareBitmapToBitmapSource(scaledBitmap);
return scaledBitmap ?? source;
}
catch (Exception)
{
@@ -141,102 +123,6 @@ namespace ImageResizer.Services
}
}
private static SoftwareBitmap ConvertBitmapSourceToSoftwareBitmap(BitmapSource bitmapSource)
{
try
{
// Ensure the bitmap is in a compatible format
var convertedBitmap = new FormatConvertedBitmap();
convertedBitmap.BeginInit();
convertedBitmap.Source = bitmapSource;
convertedBitmap.DestinationFormat = PixelFormats.Bgra32;
convertedBitmap.EndInit();
int width = convertedBitmap.PixelWidth;
int height = convertedBitmap.PixelHeight;
int stride = width * 4; // 4 bytes per pixel for Bgra32
byte[] pixels = new byte[height * stride];
convertedBitmap.CopyPixels(pixels, stride, 0);
// Create SoftwareBitmap from pixel data
var softwareBitmap = new SoftwareBitmap(
BitmapPixelFormat.Bgra8,
width,
height,
BitmapAlphaMode.Premultiplied);
using (var buffer = softwareBitmap.LockBuffer(BitmapBufferAccessMode.Write))
using (var reference = buffer.CreateReference())
{
unsafe
{
((IMemoryBufferByteAccess)reference).GetBuffer(out byte* dataInBytes, out uint capacity);
System.Runtime.InteropServices.Marshal.Copy(pixels, 0, (IntPtr)dataInBytes, pixels.Length);
}
}
return softwareBitmap;
}
catch (Exception)
{
return null;
}
}
private static BitmapSource ConvertSoftwareBitmapToBitmapSource(SoftwareBitmap softwareBitmap)
{
try
{
// Convert to Bgra8 format if needed
var convertedBitmap = SoftwareBitmap.Convert(
softwareBitmap,
BitmapPixelFormat.Bgra8,
BitmapAlphaMode.Premultiplied);
int width = convertedBitmap.PixelWidth;
int height = convertedBitmap.PixelHeight;
int stride = width * 4; // 4 bytes per pixel for Bgra8
byte[] pixels = new byte[height * stride];
using (var buffer = convertedBitmap.LockBuffer(BitmapBufferAccessMode.Read))
using (var reference = buffer.CreateReference())
{
unsafe
{
((IMemoryBufferByteAccess)reference).GetBuffer(out byte* dataInBytes, out uint capacity);
System.Runtime.InteropServices.Marshal.Copy((IntPtr)dataInBytes, pixels, 0, pixels.Length);
}
}
// Create WPF BitmapSource from pixel data
var wpfBitmap = BitmapSource.Create(
width,
height,
96, // DPI X
96, // DPI Y
PixelFormats.Bgra32,
null,
pixels,
stride);
wpfBitmap.Freeze(); // Make it thread-safe
return wpfBitmap;
}
catch (Exception)
{
return null;
}
}
[ComImport]
[Guid("5B0D3235-4DBA-4D44-865E-8F1D0E4FD04D")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMemoryBufferByteAccess
{
unsafe void GetBuffer(out byte* buffer, out uint capacity);
}
public void Dispose()
{
if (_disposed)

View File

@@ -0,0 +1,91 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using Windows.Graphics.Imaging;
namespace ImageResizer.Utilities
{
/// <summary>
/// Maps between legacy container format GUIDs (used in settings JSON) and WinRT encoder/decoder IDs,
/// and provides file extension lookups.
/// </summary>
internal static class CodecHelper
{
// Legacy container format GUID (stored in settings JSON) -> WinRT Encoder ID
private static readonly Dictionary<Guid, Guid> LegacyGuidToEncoderId = new()
{
[new Guid("19e4a5aa-5662-4fc5-a0c0-1758028e1057")] = BitmapEncoder.JpegEncoderId,
[new Guid("1b7cfaf4-713f-473c-bbcd-6137425faeaf")] = BitmapEncoder.PngEncoderId,
[new Guid("0af1d87e-fcfe-4188-bdeb-a7906471cbe3")] = BitmapEncoder.BmpEncoderId,
[new Guid("163bcc30-e2e9-4f0b-961d-a3e9fdb788a3")] = BitmapEncoder.TiffEncoderId,
[new Guid("1f8a5601-7d4d-4cbd-9c82-1bc8d4eeb9a5")] = BitmapEncoder.GifEncoderId,
};
// WinRT Decoder ID -> WinRT Encoder ID
private static readonly Dictionary<Guid, Guid> DecoderIdToEncoderId = new()
{
[BitmapDecoder.JpegDecoderId] = BitmapEncoder.JpegEncoderId,
[BitmapDecoder.PngDecoderId] = BitmapEncoder.PngEncoderId,
[BitmapDecoder.BmpDecoderId] = BitmapEncoder.BmpEncoderId,
[BitmapDecoder.TiffDecoderId] = BitmapEncoder.TiffEncoderId,
[BitmapDecoder.GifDecoderId] = BitmapEncoder.GifEncoderId,
[BitmapDecoder.JpegXRDecoderId] = BitmapEncoder.JpegXREncoderId,
};
// Encoder ID -> supported file extensions
private static readonly Dictionary<Guid, string[]> EncoderExtensions = new()
{
[BitmapEncoder.JpegEncoderId] = new[] { ".jpg", ".jpeg", ".jpe", ".jfif" },
[BitmapEncoder.PngEncoderId] = new[] { ".png" },
[BitmapEncoder.BmpEncoderId] = new[] { ".bmp", ".dib", ".rle" },
[BitmapEncoder.TiffEncoderId] = new[] { ".tiff", ".tif" },
[BitmapEncoder.GifEncoderId] = new[] { ".gif" },
[BitmapEncoder.JpegXREncoderId] = new[] { ".jxr", ".wdp" },
};
/// <summary>
/// Gets the WinRT encoder ID that corresponds to the given legacy container format GUID.
/// Falls back to PNG if the GUID is not recognized.
/// </summary>
public static Guid GetEncoderIdFromLegacyGuid(Guid containerFormatGuid)
=> LegacyGuidToEncoderId.TryGetValue(containerFormatGuid, out var id)
? id
: BitmapEncoder.PngEncoderId;
/// <summary>
/// Gets the WinRT encoder ID that matches the given decoder's codec.
/// Returns null if no matching encoder exists (e.g., ICO decoder has no encoder).
/// </summary>
public static Guid? GetEncoderIdForDecoder(BitmapDecoder decoder)
{
var codecId = decoder.DecoderInformation?.CodecId ?? Guid.Empty;
return DecoderIdToEncoderId.TryGetValue(codecId, out var encoderId)
? encoderId
: null;
}
/// <summary>
/// Returns the supported file extensions for the given encoder ID.
/// </summary>
public static string[] GetSupportedExtensions(Guid encoderId)
=> EncoderExtensions.TryGetValue(encoderId, out var extensions)
? extensions
: Array.Empty<string>();
/// <summary>
/// Returns the default (first) file extension for the given encoder ID.
/// </summary>
public static string GetDefaultExtension(Guid encoderId)
=> GetSupportedExtensions(encoderId).FirstOrDefault() ?? ".png";
/// <summary>
/// Checks whether the given encoder ID is a known, supported encoder.
/// </summary>
public static bool CanEncode(Guid encoderId)
=> EncoderExtensions.ContainsKey(encoderId);
}
}

View File

@@ -9,7 +9,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using Windows.Graphics.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Common.UI;
@@ -244,7 +244,7 @@ namespace ImageResizer.ViewModels
}
}
private void UpdateAiDetails()
private async void UpdateAiDetails()
{
if (Settings == null || Settings.SelectedSize is not AiSize)
{
@@ -262,7 +262,7 @@ namespace ImageResizer.ViewModels
return;
}
EnsureOriginalDimensionsLoaded();
await EnsureOriginalDimensionsLoadedAsync();
var hasConcreteSize = _originalWidth.HasValue && _originalHeight.HasValue;
CurrentResolutionDescription = hasConcreteSize
@@ -280,7 +280,7 @@ namespace ImageResizer.ViewModels
return string.Format(CultureInfo.CurrentCulture, "{0} x {1}", width, height);
}
private void EnsureOriginalDimensionsLoaded()
private async Task EnsureOriginalDimensionsLoadedAsync()
{
if (_originalDimensionsLoaded)
{
@@ -296,14 +296,11 @@ namespace ImageResizer.ViewModels
try
{
using var stream = File.OpenRead(file);
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.None);
var frame = decoder.Frames.FirstOrDefault();
if (frame != null)
{
_originalWidth = frame.PixelWidth;
_originalHeight = frame.PixelHeight;
}
using var fileStream = File.OpenRead(file);
using var stream = fileStream.AsRandomAccessStream();
var decoder = await BitmapDecoder.CreateAsync(stream);
_originalWidth = (int)decoder.PixelWidth;
_originalHeight = (int)decoder.PixelHeight;
}
catch (Exception)
{

View File

@@ -47,13 +47,13 @@ namespace ImageResizer.ViewModels
[RelayCommand]
public void Start()
{
_ = Task.Factory.StartNew(StartExecutingWork, _cancellationTokenSource.Token, TaskCreationOptions.None, TaskScheduler.Current);
_ = Task.Run(() => StartExecutingWorkAsync());
}
private void StartExecutingWork()
private async Task StartExecutingWorkAsync()
{
_stopwatch.Restart();
var errors = _batch.Process(
var errors = await _batch.ProcessAsync(
(completed, total) =>
{
var progress = completed / total;