Merge branch 'main' of https://github.com/microsoft/PowerToys into leilzh/imagecli

This commit is contained in:
Leilei Zhang
2025-12-15 10:25:48 +08:00
22 changed files with 1705 additions and 84 deletions

View File

@@ -124,7 +124,7 @@
<Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER &gt;= 22000" />
<Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" />
<Custom Action="RemovePowerToysSchTasks" After="RemoveFiles" />
<!-- TODO: Use to activate embedded MSIX -->

View File

@@ -6,9 +6,9 @@
using System;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using ImageResizer.Models;
using ImageResizer.Properties;
using ImageResizer.Utilities;
@@ -20,8 +20,32 @@ namespace ImageResizer
{
public partial class App : Application, IDisposable
{
private const string LogSubFolder = "\\ImageResizer\\Logs";
/// <summary>
/// Gets cached AI availability state, checked at app startup.
/// Can be updated after model download completes or background initialization.
/// </summary>
public static AiAvailabilityState AiAvailabilityState { get; internal set; }
/// <summary>
/// Event fired when AI initialization completes in background.
/// Allows UI to refresh state when initialization finishes.
/// </summary>
public static event EventHandler<AiAvailabilityState> AiInitializationCompleted;
static App()
{
try
{
// Initialize logger early (mirroring PowerOCR pattern)
Logger.InitializeLogger(LogSubFolder);
}
catch
{
/* swallow logger init issues silently */
}
try
{
string appLanguage = LanguageHelper.LoadLanguage();
@@ -30,9 +54,9 @@ namespace ImageResizer
System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(appLanguage);
}
}
catch (CultureNotFoundException)
catch (CultureNotFoundException ex)
{
// error
Logger.LogError("CultureNotFoundException: " + ex.Message);
}
Console.InputEncoding = Encoding.Unicode;
@@ -43,15 +67,59 @@ namespace ImageResizer
// Fix for .net 3.1.19 making Image Resizer not adapt to DPI changes.
NativeMethods.SetProcessDPIAware();
// Check for AI detection mode (called by Runner in background)
if (e?.Args?.Length > 0 && e.Args[0] == "--detect-ai")
{
RunAiDetectionMode();
return;
}
if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredImageResizerEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled)
{
/* TODO: Add logs to ImageResizer.
* Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
*/
Logger.LogWarning("GPO policy disables ImageResizer. Exiting.");
Environment.Exit(0); // Current.Exit won't work until there's a window opened.
return;
}
// AI Super Resolution is not supported on Windows 10 - skip cache check entirely
if (OSVersionHelper.IsWindows10())
{
AiAvailabilityState = AiAvailabilityState.NotSupported;
ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance);
Logger.LogInfo("AI Super Resolution not supported on Windows 10");
}
else
{
// Load AI availability from cache (written by Runner's background detection)
var cachedState = Services.AiAvailabilityCacheService.LoadCache();
if (cachedState.HasValue)
{
AiAvailabilityState = cachedState.Value;
Logger.LogInfo($"AI state loaded from cache: {AiAvailabilityState}");
}
else
{
// No valid cache - default to NotSupported (Runner will detect and cache for next startup)
AiAvailabilityState = AiAvailabilityState.NotSupported;
Logger.LogInfo("No AI cache found, defaulting to NotSupported");
}
// If AI is potentially available, start background initialization (non-blocking)
if (AiAvailabilityState == AiAvailabilityState.Ready)
{
_ = InitializeAiServiceAsync(); // Fire and forget - don't block UI
}
else
{
// AI not available - set NoOp service immediately
ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance);
}
}
var batch = ResizeBatch.FromCommandLine(Console.In, e?.Args);
// TODO: Add command-line parameters that can be used in lieu of the input page (issue #14)
@@ -62,9 +130,121 @@ namespace ImageResizer
WindowHelpers.BringToForeground(new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle);
}
/// <summary>
/// AI detection mode: perform detection, write to cache, and exit.
/// Called by Runner in background to avoid blocking ImageResizer UI startup.
/// </summary>
private void RunAiDetectionMode()
{
try
{
Logger.LogInfo("Running AI detection mode...");
// AI Super Resolution is not supported on Windows 10
if (OSVersionHelper.IsWindows10())
{
Logger.LogInfo("AI detection skipped: Windows 10 does not support AI Super Resolution");
Services.AiAvailabilityCacheService.SaveCache(AiAvailabilityState.NotSupported);
Environment.Exit(0);
return;
}
// Perform detection (reuse existing logic)
var state = CheckAiAvailability();
// Write result to cache file
Services.AiAvailabilityCacheService.SaveCache(state);
Logger.LogInfo($"AI detection complete: {state}");
}
catch (Exception ex)
{
Logger.LogError($"AI detection failed: {ex.Message}");
Services.AiAvailabilityCacheService.SaveCache(AiAvailabilityState.NotSupported);
}
// Exit silently without showing UI
Environment.Exit(0);
}
/// <summary>
/// Check AI Super Resolution availability on this system.
/// Performs architecture check and model availability check.
/// </summary>
private static AiAvailabilityState CheckAiAvailability()
{
try
{
// Check Windows AI service model ready state
// it's so slow, why?
var readyState = Services.WinAiSuperResolutionService.GetModelReadyState();
// Map AI service state to our availability state
switch (readyState)
{
case Microsoft.Windows.AI.AIFeatureReadyState.Ready:
return AiAvailabilityState.Ready;
case Microsoft.Windows.AI.AIFeatureReadyState.NotReady:
return AiAvailabilityState.ModelNotReady;
case Microsoft.Windows.AI.AIFeatureReadyState.DisabledByUser:
case Microsoft.Windows.AI.AIFeatureReadyState.NotSupportedOnCurrentSystem:
default:
return AiAvailabilityState.NotSupported;
}
}
catch (Exception)
{
return AiAvailabilityState.NotSupported;
}
}
/// <summary>
/// Initialize AI Super Resolution service asynchronously in background.
/// Runs without blocking UI startup - state change event notifies completion.
/// </summary>
private static async System.Threading.Tasks.Task InitializeAiServiceAsync()
{
AiAvailabilityState finalState;
try
{
// Create and initialize AI service using async factory
var aiService = await Services.WinAiSuperResolutionService.CreateAsync();
if (aiService != null)
{
ResizeBatch.SetAiSuperResolutionService(aiService);
Logger.LogInfo("AI Super Resolution service initialized successfully.");
finalState = AiAvailabilityState.Ready;
}
else
{
// Initialization failed - use default NoOp service
ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance);
Logger.LogWarning("AI Super Resolution service initialization failed. Using default service.");
finalState = AiAvailabilityState.NotSupported;
}
}
catch (Exception ex)
{
// Log error and use default NoOp service
ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance);
Logger.LogError($"Exception during AI service initialization: {ex.Message}");
finalState = AiAvailabilityState.NotSupported;
}
// Update cached state and notify listeners
AiAvailabilityState = finalState;
AiInitializationCompleted?.Invoke(null, finalState);
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose AI Super Resolution service
ResizeBatch.DisposeAiSuperResolutionService();
GC.SuppressFinalize(this);
}
}

View File

@@ -10,6 +10,7 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
<UseWPF>true</UseWPF>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
</PropertyGroup>
<PropertyGroup>
@@ -18,19 +19,20 @@
<RootNamespace>ImageResizer</RootNamespace>
<AssemblyName>PowerToys.ImageResizer</AssemblyName>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>Resources\ImageResizer.ico</ApplicationIcon>
</PropertyGroup>
<!-- <PropertyGroup>
<PropertyGroup>
<ApplicationManifest>ImageResizerUI.dev.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition="'$(CIBuild)'=='true'">
<ApplicationManifest>ImageResizerUI.prod.manifest</ApplicationManifest>
</PropertyGroup> -->
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
@@ -46,6 +48,8 @@
<Resource Include="Resources\ImageResizer.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="System.IO.Abstractions" />

View File

@@ -0,0 +1,41 @@
// 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.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using ImageResizer.Properties;
namespace ImageResizer.Models
{
public class AiSize : ResizeSize
{
private static readonly CompositeFormat ScaleFormat = CompositeFormat.Parse(Resources.Input_AiScaleFormat);
private int _scale = 2;
/// <summary>
/// Gets the formatted scale display string (e.g., "2×").
/// </summary>
[JsonIgnore]
public string ScaleDisplay => string.Format(CultureInfo.CurrentCulture, ScaleFormat, _scale);
[JsonPropertyName("scale")]
public int Scale
{
get => _scale;
set => Set(ref _scale, value);
}
[JsonConstructor]
public AiSize(int scale)
{
Scale = scale;
}
public AiSize()
{
}
}
}

View File

@@ -15,17 +15,30 @@ using System.Threading;
using System.Threading.Tasks;
using ImageResizer.Properties;
using ImageResizer.Services;
namespace ImageResizer.Models
{
public class ResizeBatch
{
private readonly IFileSystem _fileSystem = new FileSystem();
private static IAISuperResolutionService _aiSuperResolutionService;
public string DestinationDirectory { get; set; }
public ICollection<string> Files { get; } = new List<string>();
public static void SetAiSuperResolutionService(IAISuperResolutionService service)
{
_aiSuperResolutionService = service;
}
public static void DisposeAiSuperResolutionService()
{
_aiSuperResolutionService?.Dispose();
_aiSuperResolutionService = null;
}
/// <summary>
/// Creates a ResizeBatch from CliOptions.
/// </summary>
@@ -132,6 +145,9 @@ namespace ImageResizer.Models
}
protected virtual void Execute(string file, Settings settings)
=> new ResizeOperation(file, DestinationDirectory, settings).Execute();
{
var aiService = _aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
new ResizeOperation(file, DestinationDirectory, settings, aiService).Execute();
}
}
}

View File

@@ -10,12 +10,14 @@ 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 ImageResizer.Properties;
using ImageResizer.Services;
using ImageResizer.Utilities;
using Microsoft.VisualBasic.FileIO;
@@ -30,6 +32,10 @@ namespace ImageResizer.Models
private readonly string _file;
private readonly string _destinationDirectory;
private readonly Settings _settings;
private readonly IAISuperResolutionService _aiSuperResolutionService;
// Cache CompositeFormat for AI error message formatting (CA1863)
private static readonly CompositeFormat _aiErrorFormat = CompositeFormat.Parse(Resources.Error_AiProcessingFailed);
// Filenames to avoid according to https://learn.microsoft.com/windows/win32/fileio/naming-a-file#file-and-directory-names
private static readonly string[] _avoidFilenames =
@@ -39,11 +45,12 @@ namespace ImageResizer.Models
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
};
public ResizeOperation(string file, string destinationDirectory, Settings settings)
public ResizeOperation(string file, string destinationDirectory, Settings settings, IAISuperResolutionService aiSuperResolutionService = null)
{
_file = file;
_destinationDirectory = destinationDirectory;
_settings = settings;
_aiSuperResolutionService = aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
}
public void Execute()
@@ -167,6 +174,11 @@ namespace ImageResizer.Models
private BitmapSource Transform(BitmapSource source)
{
if (_settings.SelectedSize is AiSize)
{
return TransformWithAi(source);
}
int originalWidth = source.PixelWidth;
int originalHeight = source.PixelHeight;
@@ -257,6 +269,31 @@ namespace ImageResizer.Models
return scaledBitmap;
}
private BitmapSource TransformWithAi(BitmapSource source)
{
try
{
var result = _aiSuperResolutionService.ApplySuperResolution(
source,
_settings.AiSize.Scale,
_file);
if (result == null)
{
throw new InvalidOperationException(Properties.Resources.Error_AiConversionFailed);
}
return result;
}
catch (Exception ex)
{
// Wrap the exception with a localized message
// This will be caught by ResizeBatch.Process() and displayed to the user
var errorMessage = string.Format(CultureInfo.CurrentCulture, _aiErrorFormat, ex.Message);
throw new InvalidOperationException(errorMessage, ex);
}
}
/// <summary>
/// Checks original metadata by writing an image containing the given metadata into a memory stream.
/// In case of errors, we try to rebuild the metadata object and check again.
@@ -363,19 +400,24 @@ namespace ImageResizer.Models
}
// Remove directory characters from the size's name.
string sizeNameSanitized = _settings.SelectedSize.Name;
sizeNameSanitized = sizeNameSanitized
// For AI Size, use the scale display (e.g., "2×") instead of the full name
string sizeName = _settings.SelectedSize is AiSize aiSize
? aiSize.ScaleDisplay
: _settings.SelectedSize.Name;
string sizeNameSanitized = sizeName
.Replace('\\', '_')
.Replace('/', '_');
// Using CurrentCulture since this is user facing
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 fileName = string.Format(
CultureInfo.CurrentCulture,
_settings.FileNameFormat,
originalFileName,
sizeNameSanitized,
_settings.SelectedSize.Width,
_settings.SelectedSize.Height,
selectedWidth,
selectedHeight,
encoder.Frames[0].PixelWidth,
encoder.Frames[0].PixelHeight);

View File

@@ -19,7 +19,7 @@ namespace ImageResizer.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
@@ -78,6 +78,33 @@ namespace ImageResizer.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Failed to convert image format for AI processing..
/// </summary>
public static string Error_AiConversionFailed {
get {
return ResourceManager.GetString("Error_AiConversionFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to AI super resolution processing failed: {0}.
/// </summary>
public static string Error_AiProcessingFailed {
get {
return ResourceManager.GetString("Error_AiProcessingFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to AI scaling operation failed..
/// </summary>
public static string Error_AiScalingFailed {
get {
return ResourceManager.GetString("Error_AiScalingFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Height.
/// </summary>
@@ -105,6 +132,132 @@ namespace ImageResizer.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Current:.
/// </summary>
public static string Input_AiCurrentLabel {
get {
return ResourceManager.GetString("Input_AiCurrentLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Checking AI model availability....
/// </summary>
public static string Input_AiModelChecking {
get {
return ResourceManager.GetString("Input_AiModelChecking", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to AI feature is disabled by system settings..
/// </summary>
public static string Input_AiModelDisabledByUser {
get {
return ResourceManager.GetString("Input_AiModelDisabledByUser", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Download.
/// </summary>
public static string Input_AiModelDownloadButton {
get {
return ResourceManager.GetString("Input_AiModelDownloadButton", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to download AI model. Please try again..
/// </summary>
public static string Input_AiModelDownloadFailed {
get {
return ResourceManager.GetString("Input_AiModelDownloadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downloading AI model....
/// </summary>
public static string Input_AiModelDownloading {
get {
return ResourceManager.GetString("Input_AiModelDownloading", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to AI model not downloaded. Click Download to get started..
/// </summary>
public static string Input_AiModelNotAvailable {
get {
return ResourceManager.GetString("Input_AiModelNotAvailable", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to AI feature is not supported on this system..
/// </summary>
public static string Input_AiModelNotSupported {
get {
return ResourceManager.GetString("Input_AiModelNotSupported", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to New:.
/// </summary>
public static string Input_AiNewLabel {
get {
return ResourceManager.GetString("Input_AiNewLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}×.
/// </summary>
public static string Input_AiScaleFormat {
get {
return ResourceManager.GetString("Input_AiScaleFormat", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Scale.
/// </summary>
public static string Input_AiScaleLabel {
get {
return ResourceManager.GetString("Input_AiScaleLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Super resolution.
/// </summary>
public static string Input_AiSuperResolution {
get {
return ResourceManager.GetString("Input_AiSuperResolution", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Upscale images using on-device AI.
/// </summary>
public static string Input_AiSuperResolutionDescription {
get {
return ResourceManager.GetString("Input_AiSuperResolutionDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unavailable.
/// </summary>
public static string Input_AiUnknownSize {
get {
return ResourceManager.GetString("Input_AiUnknownSize", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to (auto).
/// </summary>

View File

@@ -296,4 +296,55 @@
<data name="Input_ShrinkOnly.Content" xml:space="preserve">
<value>_Make pictures smaller but not larger</value>
</data>
<data name="Input_AiSuperResolution" xml:space="preserve">
<value>Super resolution</value>
</data>
<data name="Input_AiUnknownSize" xml:space="preserve">
<value>Unavailable</value>
</data>
<data name="Input_AiScaleFormat" xml:space="preserve">
<value>{0}×</value>
</data>
<data name="Input_AiScaleLabel" xml:space="preserve">
<value>Scale</value>
</data>
<data name="Input_AiCurrentLabel" xml:space="preserve">
<value>Current:</value>
</data>
<data name="Input_AiNewLabel" xml:space="preserve">
<value>New:</value>
</data>
<data name="Input_AiModelChecking" xml:space="preserve">
<value>Checking AI model availability...</value>
</data>
<data name="Input_AiModelNotAvailable" xml:space="preserve">
<value>AI model not downloaded. Click Download to get started.</value>
</data>
<data name="Input_AiModelDisabledByUser" xml:space="preserve">
<value>AI feature is disabled by system settings.</value>
</data>
<data name="Input_AiModelNotSupported" xml:space="preserve">
<value>AI feature is not supported on this system.</value>
</data>
<data name="Input_AiModelDownloading" xml:space="preserve">
<value>Downloading AI model...</value>
</data>
<data name="Input_AiModelDownloadFailed" xml:space="preserve">
<value>Failed to download AI model. Please try again.</value>
</data>
<data name="Input_AiModelDownloadButton" xml:space="preserve">
<value>Download</value>
</data>
<data name="Error_AiProcessingFailed" xml:space="preserve">
<value>AI super resolution processing failed: {0}</value>
</data>
<data name="Error_AiConversionFailed" xml:space="preserve">
<value>Failed to convert image format for AI processing.</value>
</data>
<data name="Error_AiScalingFailed" xml:space="preserve">
<value>AI scaling operation failed.</value>
</data>
<data name="Input_AiSuperResolutionDescription" xml:space="preserve">
<value>Upscale images using on-device AI</value>
</data>
</root>

View File

@@ -20,10 +20,22 @@ using System.Threading;
using System.Windows.Media.Imaging;
using ImageResizer.Models;
using ImageResizer.Services;
using ImageResizer.ViewModels;
using ManagedCommon;
namespace ImageResizer.Properties
{
/// <summary>
/// Represents the availability state of AI Super Resolution feature.
/// </summary>
public enum AiAvailabilityState
{
NotSupported, // System doesn't support AI (architecture issue or policy disabled)
ModelNotReady, // AI supported but model not downloaded
Ready, // AI fully ready to use
}
public sealed partial class Settings : IDataErrorInfo, INotifyPropertyChanged
{
private static readonly IFileSystem _fileSystem = new FileSystem();
@@ -52,6 +64,7 @@ namespace ImageResizer.Properties
private bool _keepDateModified;
private System.Guid _fallbackEncoder;
private CustomSize _customSize;
private AiSize _aiSize;
public Settings()
{
@@ -74,9 +87,28 @@ namespace ImageResizer.Properties
KeepDateModified = false;
FallbackEncoder = new System.Guid("19e4a5aa-5662-4fc5-a0c0-1758028e1057");
CustomSize = new CustomSize(ResizeFit.Fit, 1024, 640, ResizeUnit.Pixel);
AiSize = new AiSize(2); // Initialize with default scale of 2
AllSizes = new AllSizesCollection(this);
}
/// <summary>
/// Validates the SelectedSizeIndex to ensure it's within the valid range.
/// This handles cross-device migration where settings saved on ARM64 with AI selected
/// are loaded on non-ARM64 devices.
/// </summary>
private void ValidateSelectedSizeIndex()
{
// Index structure: 0 to Sizes.Count-1 (regular), Sizes.Count (CustomSize), Sizes.Count+1 (AiSize)
var maxIndex = ImageResizer.App.AiAvailabilityState == AiAvailabilityState.NotSupported
? Sizes.Count // CustomSize only
: Sizes.Count + 1; // CustomSize + AiSize
if (_selectedSizeIndex > maxIndex)
{
_selectedSizeIndex = 0; // Reset to first size
}
}
[JsonIgnore]
public IEnumerable<ResizeSize> AllSizes { get; set; }
@@ -96,15 +128,40 @@ namespace ImageResizer.Properties
[JsonIgnore]
public ResizeSize SelectedSize
{
get => SelectedSizeIndex >= 0 && SelectedSizeIndex < Sizes.Count
? Sizes[SelectedSizeIndex]
: CustomSize;
get
{
if (SelectedSizeIndex >= 0 && SelectedSizeIndex < Sizes.Count)
{
return Sizes[SelectedSizeIndex];
}
else if (SelectedSizeIndex == Sizes.Count)
{
return CustomSize;
}
else if (ImageResizer.App.AiAvailabilityState != AiAvailabilityState.NotSupported && SelectedSizeIndex == Sizes.Count + 1)
{
return AiSize;
}
else
{
// Fallback to CustomSize when index is out of range or AI is not available
return CustomSize;
}
}
set
{
var index = Sizes.IndexOf(value);
if (index == -1)
{
index = Sizes.Count;
if (value is AiSize)
{
index = Sizes.Count + 1;
}
else
{
index = Sizes.Count;
}
}
SelectedSizeIndex = index;
@@ -140,13 +197,17 @@ namespace ImageResizer.Properties
private class AllSizesCollection : IEnumerable<ResizeSize>, INotifyCollectionChanged, INotifyPropertyChanged
{
private readonly Settings _settings;
private ObservableCollection<ResizeSize> _sizes;
private CustomSize _customSize;
private AiSize _aiSize;
public AllSizesCollection(Settings settings)
{
_settings = settings;
_sizes = settings.Sizes;
_customSize = settings.CustomSize;
_aiSize = settings.AiSize;
_sizes.CollectionChanged += HandleCollectionChanged;
((INotifyPropertyChanged)_sizes).PropertyChanged += HandlePropertyChanged;
@@ -165,6 +226,18 @@ namespace ImageResizer.Properties
oldCustomSize,
_sizes.Count));
}
else if (e.PropertyName == nameof(Models.AiSize))
{
var oldAiSize = _aiSize;
_aiSize = settings.AiSize;
OnCollectionChanged(
new NotifyCollectionChangedEventArgs(
NotifyCollectionChangedAction.Replace,
_aiSize,
oldAiSize,
_sizes.Count + 1));
}
else if (e.PropertyName == nameof(Sizes))
{
var oldSizes = _sizes;
@@ -187,12 +260,30 @@ namespace ImageResizer.Properties
public event PropertyChangedEventHandler PropertyChanged;
public int Count
=> _sizes.Count + 1;
=> _sizes.Count + 1 + (ImageResizer.App.AiAvailabilityState != AiAvailabilityState.NotSupported ? 1 : 0);
public ResizeSize this[int index]
=> index == _sizes.Count
? _customSize
: _sizes[index];
{
get
{
if (index < _sizes.Count)
{
return _sizes[index];
}
else if (index == _sizes.Count)
{
return _customSize;
}
else if (ImageResizer.App.AiAvailabilityState != AiAvailabilityState.NotSupported && index == _sizes.Count + 1)
{
return _aiSize;
}
else
{
throw new ArgumentOutOfRangeException(nameof(index), index, $"Index {index} is out of range for AllSizesCollection.");
}
}
}
public IEnumerator<ResizeSize> GetEnumerator()
=> new AllSizesEnumerator(this);
@@ -412,6 +503,18 @@ namespace ImageResizer.Properties
}
}
[JsonConverter(typeof(WrappedJsonValueConverter))]
[JsonPropertyName("imageresizer_aiSize")]
public AiSize AiSize
{
get => _aiSize;
set
{
_aiSize = value;
NotifyPropertyChanged();
}
}
public static string SettingsPath { get => _settingsPath; set => _settingsPath = value; }
public event PropertyChangedEventHandler PropertyChanged;
@@ -489,6 +592,7 @@ namespace ImageResizer.Properties
KeepDateModified = jsonSettings.KeepDateModified;
FallbackEncoder = jsonSettings.FallbackEncoder;
CustomSize = jsonSettings.CustomSize;
AiSize = jsonSettings.AiSize ?? new AiSize(InputViewModel.DefaultAiScale);
SelectedSizeIndex = jsonSettings.SelectedSizeIndex;
if (jsonSettings.Sizes.Count > 0)
@@ -499,6 +603,10 @@ namespace ImageResizer.Properties
// Ensure Ids are unique and handle missing Ids
IdRecoveryHelper.RecoverInvalidIds(Sizes);
}
// Validate SelectedSizeIndex after Sizes collection has been updated
// This handles cross-device migration (e.g., ARM64 -> non-ARM64)
ValidateSelectedSizeIndex();
}
}
}

View File

@@ -0,0 +1,125 @@
// 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.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using ImageResizer.Properties;
using ManagedCommon;
namespace ImageResizer.Services
{
/// <summary>
/// Service for caching AI availability detection results.
/// Persists results to avoid slow API calls on every startup.
/// Runner calls ImageResizer --detect-ai to perform detection,
/// and ImageResizer reads the cached result on normal startup.
/// </summary>
public static class AiAvailabilityCacheService
{
private const string CacheFileName = "ai_capabilities.json";
private const int CacheVersion = 1;
private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions
{
WriteIndented = true,
};
private static string CachePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
CacheFileName);
/// <summary>
/// Load AI availability state from cache.
/// Returns null if cache doesn't exist, is invalid, or read fails.
/// </summary>
public static AiAvailabilityState? LoadCache()
{
try
{
if (!File.Exists(CachePath))
{
return null;
}
var json = File.ReadAllText(CachePath);
var cache = JsonSerializer.Deserialize<AiCapabilityCache>(json);
if (!IsCacheValid(cache))
{
return null;
}
return (AiAvailabilityState)cache.State;
}
catch (Exception ex)
{
// Read failure (file locked, corrupted JSON, etc.) - return null and use fallback
Logger.LogError($"Failed to load AI cache: {ex.Message}");
return null;
}
}
/// <summary>
/// Save AI availability state to cache.
/// Called by --detect-ai mode after performing detection.
/// </summary>
public static void SaveCache(AiAvailabilityState state)
{
try
{
var cache = new AiCapabilityCache
{
Version = CacheVersion,
State = (int)state,
WindowsBuild = Environment.OSVersion.Version.ToString(),
Architecture = RuntimeInformation.ProcessArchitecture.ToString(),
Timestamp = DateTime.UtcNow.ToString("o"),
};
var dir = Path.GetDirectoryName(CachePath);
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(cache, SerializerOptions);
File.WriteAllText(CachePath, json);
Logger.LogInfo($"AI cache saved: {state}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to save AI cache: {ex.Message}");
}
}
/// <summary>
/// Validate cache against current system environment.
/// Cache is invalid if version, architecture, or Windows build changed.
/// </summary>
private static bool IsCacheValid(AiCapabilityCache cache)
{
if (cache == null || cache.Version != CacheVersion)
{
return false;
}
if (cache.Architecture != RuntimeInformation.ProcessArchitecture.ToString())
{
return false;
}
if (cache.WindowsBuild != Environment.OSVersion.Version.ToString())
{
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,22 @@
// 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.Services
{
/// <summary>
/// Data model for AI capability cache file.
/// </summary>
internal sealed class AiCapabilityCache
{
public int Version { get; set; }
public int State { get; set; }
public string WindowsBuild { get; set; }
public string Architecture { get; set; }
public string Timestamp { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
// 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.Windows.Media.Imaging;
namespace ImageResizer.Services
{
public interface IAISuperResolutionService : IDisposable
{
BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath);
}
}

View File

@@ -0,0 +1,27 @@
// 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.Windows.Media.Imaging;
namespace ImageResizer.Services
{
public sealed class NoOpAiSuperResolutionService : IAISuperResolutionService
{
public static NoOpAiSuperResolutionService Instance { get; } = new NoOpAiSuperResolutionService();
private NoOpAiSuperResolutionService()
{
}
public BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath)
{
return source;
}
public void Dispose()
{
// No resources to dispose in no-op implementation
}
}
}

View File

@@ -0,0 +1,261 @@
// 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.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;
namespace ImageResizer.Services
{
public sealed class WinAiSuperResolutionService : IAISuperResolutionService
{
private readonly ImageScaler _imageScaler;
private readonly object _usageLock = new object();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="WinAiSuperResolutionService"/> class.
/// Private constructor. Use CreateAsync() factory method to create instances.
/// </summary>
private WinAiSuperResolutionService(ImageScaler imageScaler)
{
_imageScaler = imageScaler ?? throw new ArgumentNullException(nameof(imageScaler));
}
/// <summary>
/// Async factory method to create and initialize WinAiSuperResolutionService.
/// Returns null if initialization fails.
/// </summary>
public static async Task<WinAiSuperResolutionService> CreateAsync()
{
try
{
var imageScaler = await ImageScaler.CreateAsync();
if (imageScaler == null)
{
return null;
}
return new WinAiSuperResolutionService(imageScaler);
}
catch
{
return null;
}
}
public static AIFeatureReadyState GetModelReadyState()
{
try
{
return ImageScaler.GetReadyState();
}
catch (Exception)
{
// If we can't get the state, treat it as disabled by user
// The caller should check if it's Ready or NotReady
return AIFeatureReadyState.DisabledByUser;
}
}
public static async Task<AIFeatureReadyResult> EnsureModelReadyAsync(IProgress<double> progress = null)
{
try
{
var operation = ImageScaler.EnsureReadyAsync();
// Register progress handler if provided
if (progress != null)
{
operation.Progress = (asyncInfo, progressValue) =>
{
// progressValue is a double representing completion percentage (0.0 to 1.0 or 0 to 100)
progress.Report(progressValue);
};
}
return await operation;
}
catch (Exception)
{
return null;
}
}
public BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath)
{
if (source == null || _disposed)
{
return source;
}
// Note: filePath parameter reserved for future use (e.g., logging, caching)
// 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;
// Apply super resolution with thread-safe access
// _usageLock protects concurrent access from Parallel.ForEach threads
SoftwareBitmap scaledBitmap;
lock (_usageLock)
{
if (_disposed)
{
return source;
}
scaledBitmap = _imageScaler.ScaleSoftwareBitmap(softwareBitmap, newWidth, newHeight);
}
if (scaledBitmap == null)
{
return source;
}
// Convert back to WPF BitmapSource
return ConvertSoftwareBitmapToBitmapSource(scaledBitmap);
}
catch (Exception)
{
// Any error, return original image gracefully
return source;
}
}
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)
{
return;
}
lock (_usageLock)
{
if (_disposed)
{
return;
}
// ImageScaler implements IDisposable
(_imageScaler as IDisposable)?.Dispose();
_disposed = true;
}
}
}
}

View File

@@ -6,22 +6,41 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using Common.UI;
using ImageResizer.Helpers;
using ImageResizer.Models;
using ImageResizer.Properties;
using ImageResizer.Services;
using ImageResizer.Views;
namespace ImageResizer.ViewModels
{
public class InputViewModel : Observable
{
public const int DefaultAiScale = 2;
private const int MinAiScale = 1;
private const int MaxAiScale = 8;
private readonly ResizeBatch _batch;
private readonly MainViewModel _mainViewModel;
private readonly IMainView _mainView;
private readonly bool _hasMultipleFiles;
private bool _originalDimensionsLoaded;
private int? _originalWidth;
private int? _originalHeight;
private string _currentResolutionDescription;
private string _newResolutionDescription;
private bool _isDownloadingModel;
private string _modelStatusMessage;
private double _modelDownloadProgress;
public enum Dimension
{
@@ -45,24 +64,114 @@ namespace ImageResizer.ViewModels
_batch = batch;
_mainViewModel = mainViewModel;
_mainView = mainView;
_hasMultipleFiles = _batch?.Files.Count > 1;
Settings = settings;
if (settings != null)
{
settings.CustomSize.PropertyChanged += (sender, e) => settings.SelectedSize = (CustomSize)sender;
settings.AiSize.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(AiSize.Scale))
{
NotifyAiScaleChanged();
}
};
settings.PropertyChanged += HandleSettingsPropertyChanged;
}
ResizeCommand = new RelayCommand(Resize);
ResizeCommand = new RelayCommand(Resize, () => CanResize);
CancelCommand = new RelayCommand(Cancel);
OpenSettingsCommand = new RelayCommand(OpenSettings);
EnterKeyPressedCommand = new RelayCommand<KeyPressParams>(HandleEnterKeyPress);
DownloadModelCommand = new RelayCommand(async () => await DownloadModelAsync());
// Initialize AI UI state based on Settings availability
InitializeAiState();
}
public Settings Settings { get; }
public IEnumerable<ResizeFit> ResizeFitValues => Enum.GetValues(typeof(ResizeFit)).Cast<ResizeFit>();
public IEnumerable<ResizeFit> ResizeFitValues => Enum.GetValues<ResizeFit>();
public IEnumerable<ResizeUnit> ResizeUnitValues => Enum.GetValues(typeof(ResizeUnit)).Cast<ResizeUnit>();
public IEnumerable<ResizeUnit> ResizeUnitValues => Enum.GetValues<ResizeUnit>();
public int AiSuperResolutionScale
{
get => Settings?.AiSize?.Scale ?? DefaultAiScale;
set
{
if (Settings?.AiSize != null && Settings.AiSize.Scale != value)
{
Settings.AiSize.Scale = value;
NotifyAiScaleChanged();
}
}
}
public string AiScaleDisplay => Settings?.AiSize?.ScaleDisplay ?? string.Empty;
public string CurrentResolutionDescription
{
get => _currentResolutionDescription;
private set => Set(ref _currentResolutionDescription, value);
}
public string NewResolutionDescription
{
get => _newResolutionDescription;
private set => Set(ref _newResolutionDescription, value);
}
// ==================== UI State Properties ====================
// Show AI size descriptions only when AI size is selected and not multiple files
public bool ShowAiSizeDescriptions => Settings?.SelectedSize is AiSize && !_hasMultipleFiles;
// Helper property: Is model currently being downloaded?
public bool IsModelDownloading => _isDownloadingModel;
public string ModelStatusMessage
{
get => _modelStatusMessage;
private set => Set(ref _modelStatusMessage, value);
}
public double ModelDownloadProgress
{
get => _modelDownloadProgress;
private set => Set(ref _modelDownloadProgress, value);
}
// Show download prompt when: AI size is selected and model is not ready (including downloading)
public bool ShowModelDownloadPrompt =>
Settings?.SelectedSize is AiSize &&
(App.AiAvailabilityState == Properties.AiAvailabilityState.ModelNotReady || _isDownloadingModel);
// Show AI controls when: AI size is selected and AI is ready
public bool ShowAiControls =>
Settings?.SelectedSize is AiSize &&
App.AiAvailabilityState == Properties.AiAvailabilityState.Ready;
/// <summary>
/// Gets a value indicating whether the resize operation can proceed.
/// For AI resize: only enabled when AI is fully ready.
/// For non-AI resize: always enabled.
/// </summary>
public bool CanResize
{
get
{
// If AI size is selected, only allow resize when AI is fully ready
if (Settings?.SelectedSize is AiSize)
{
return App.AiAvailabilityState == Properties.AiAvailabilityState.Ready;
}
// Non-AI resize can always proceed
return true;
}
}
public ICommand ResizeCommand { get; }
@@ -72,9 +181,11 @@ namespace ImageResizer.ViewModels
public ICommand EnterKeyPressedCommand { get; private set; }
public ICommand DownloadModelCommand { get; private set; }
// Any of the files is a gif
public bool TryingToResizeGifFiles =>
_batch.Files.Any(filename => filename.EndsWith(".gif", System.StringComparison.InvariantCultureIgnoreCase));
_batch?.Files.Any(filename => filename.EndsWith(".gif", System.StringComparison.InvariantCultureIgnoreCase)) == true;
public void Resize()
{
@@ -102,5 +213,234 @@ namespace ImageResizer.ViewModels
public void Cancel()
=> _mainView.Close();
private void HandleSettingsPropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(Settings.SelectedSizeIndex):
case nameof(Settings.SelectedSize):
// Notify UI state properties that depend on SelectedSize
NotifyAiStateChanged();
UpdateAiDetails();
// Trigger CanExecuteChanged for ResizeCommand
if (ResizeCommand is RelayCommand cmd)
{
cmd.OnCanExecuteChanged();
}
break;
}
}
private void EnsureAiScaleWithinRange()
{
if (Settings?.AiSize != null)
{
Settings.AiSize.Scale = Math.Clamp(
Settings.AiSize.Scale,
MinAiScale,
MaxAiScale);
}
}
private void UpdateAiDetails()
{
// Clear AI details if AI size not selected
if (Settings == null || Settings.SelectedSize is not AiSize)
{
CurrentResolutionDescription = string.Empty;
NewResolutionDescription = string.Empty;
return;
}
EnsureAiScaleWithinRange();
if (_hasMultipleFiles)
{
CurrentResolutionDescription = string.Empty;
NewResolutionDescription = string.Empty;
return;
}
EnsureOriginalDimensionsLoaded();
var hasConcreteSize = _originalWidth.HasValue && _originalHeight.HasValue;
CurrentResolutionDescription = hasConcreteSize
? FormatDimensions(_originalWidth!.Value, _originalHeight!.Value)
: Resources.Input_AiUnknownSize;
var scale = Settings.AiSize.Scale;
NewResolutionDescription = hasConcreteSize
? FormatDimensions((long)_originalWidth!.Value * scale, (long)_originalHeight!.Value * scale)
: Resources.Input_AiUnknownSize;
}
private static string FormatDimensions(long width, long height)
{
return string.Format(CultureInfo.CurrentCulture, "{0} × {1}", width, height);
}
private void EnsureOriginalDimensionsLoaded()
{
if (_originalDimensionsLoaded)
{
return;
}
var file = _batch?.Files.FirstOrDefault();
if (string.IsNullOrEmpty(file))
{
_originalDimensionsLoaded = true;
return;
}
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;
}
}
catch (Exception)
{
// Failed to load image dimensions - clear values
_originalWidth = null;
_originalHeight = null;
}
finally
{
_originalDimensionsLoaded = true;
}
}
/// <summary>
/// Initializes AI UI state based on App's cached availability state.
/// Subscribe to state change event to update UI when background initialization completes.
/// </summary>
private void InitializeAiState()
{
// Subscribe to initialization completion event to refresh UI
App.AiInitializationCompleted += OnAiInitializationCompleted;
// Set initial status message based on current state
UpdateStatusMessage();
}
/// <summary>
/// Handles AI initialization completion event from App.
/// Refreshes UI when background initialization finishes.
/// </summary>
private void OnAiInitializationCompleted(object sender, Properties.AiAvailabilityState finalState)
{
UpdateStatusMessage();
NotifyAiStateChanged();
}
/// <summary>
/// Updates status message based on current App availability state.
/// </summary>
private void UpdateStatusMessage()
{
ModelStatusMessage = App.AiAvailabilityState switch
{
Properties.AiAvailabilityState.Ready => string.Empty,
Properties.AiAvailabilityState.ModelNotReady => Resources.Input_AiModelNotAvailable,
Properties.AiAvailabilityState.NotSupported => Resources.Input_AiModelNotSupported,
_ => string.Empty,
};
}
/// <summary>
/// Notifies UI when AI state changes (model availability, download status).
/// </summary>
private void NotifyAiStateChanged()
{
OnPropertyChanged(nameof(IsModelDownloading));
OnPropertyChanged(nameof(ShowModelDownloadPrompt));
OnPropertyChanged(nameof(ShowAiControls));
OnPropertyChanged(nameof(ShowAiSizeDescriptions));
OnPropertyChanged(nameof(CanResize));
// Trigger CanExecuteChanged for ResizeCommand
if (ResizeCommand is RelayCommand resizeCommand)
{
resizeCommand.OnCanExecuteChanged();
}
}
/// <summary>
/// Notifies UI when AI scale changes (slider value).
/// </summary>
private void NotifyAiScaleChanged()
{
OnPropertyChanged(nameof(AiSuperResolutionScale));
OnPropertyChanged(nameof(AiScaleDisplay));
UpdateAiDetails();
}
private async Task DownloadModelAsync()
{
try
{
// Set downloading flag and show progress
_isDownloadingModel = true;
ModelStatusMessage = Resources.Input_AiModelDownloading;
ModelDownloadProgress = 0;
NotifyAiStateChanged();
// Create progress reporter to update UI
var progress = new Progress<double>(value =>
{
// progressValue could be 0-1 or 0-100, normalize to 0-100
ModelDownloadProgress = value > 1 ? value : value * 100;
});
// Call EnsureReadyAsync to download and prepare the AI model
var result = await WinAiSuperResolutionService.EnsureModelReadyAsync(progress);
if (result?.Status == Microsoft.Windows.AI.AIFeatureReadyResultState.Success)
{
// Model successfully downloaded and ready
ModelDownloadProgress = 100;
// Update App's cached state
App.AiAvailabilityState = Properties.AiAvailabilityState.Ready;
UpdateStatusMessage();
// Initialize the AI service now that model is ready
var aiService = await WinAiSuperResolutionService.CreateAsync();
ResizeBatch.SetAiSuperResolutionService(aiService ?? (Services.IAISuperResolutionService)NoOpAiSuperResolutionService.Instance);
}
else
{
// Download failed
ModelStatusMessage = Resources.Input_AiModelDownloadFailed;
}
}
catch (Exception)
{
// Exception during download
ModelStatusMessage = Resources.Input_AiModelDownloadFailed;
}
finally
{
// Clear downloading flag
_isDownloadingModel = false;
// Reset progress if not successful
if (App.AiAvailabilityState != Properties.AiAvailabilityState.Ready)
{
ModelDownloadProgress = 0;
}
NotifyAiStateChanged();
}
}
}
}

View File

@@ -11,11 +11,21 @@ using System.Windows.Data;
namespace ImageResizer.Views
{
[ValueConversion(typeof(Enum), typeof(string))]
[ValueConversion(typeof(bool), typeof(Visibility))]
internal class BoolValueConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (bool)value ? Visibility.Visible : Visibility.Collapsed;
{
bool boolValue = (bool)value;
bool invert = parameter is string param && param.Equals("Inverted", StringComparison.OrdinalIgnoreCase);
if (invert)
{
boolValue = !boolValue;
}
return boolValue ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> (Visibility)value == Visibility.Visible;

View File

@@ -7,6 +7,23 @@
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:v="clr-namespace:ImageResizer.Views">
<UserControl.Resources>
<Style
x:Key="ReadableDisabledButtonStyle"
BasedOn="{StaticResource {x:Type ui:Button}}"
TargetType="ui:Button">
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<!-- Improved disabled state: keep readable but clearly disabled -->
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
<Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" />
<Setter Property="BorderBrush" Value="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}" />
<Setter Property="Opacity" Value="0.75" />
</Trigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -15,61 +32,67 @@
<!-- other controls -->
</Grid.RowDefinitions>
<ComboBox
Name="SizeComboBox"
Grid.Row="0"
Height="64"
Margin="16"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.HelpText="{Binding Settings.SelectedSize, Converter={StaticResource SizeTypeToHelpTextConverter}}"
AutomationProperties.Name="{x:Static p:Resources.Image_Sizes}"
ItemsSource="{Binding Settings.AllSizes}"
SelectedIndex="{Binding Settings.SelectedSizeIndex}">
<ComboBox.ItemContainerStyle>
<Style BasedOn="{StaticResource {x:Type ComboBoxItem}}" TargetType="ComboBoxItem">
<Setter Property="AutomationProperties.Name" Value="{Binding Name}" />
<Setter Property="AutomationProperties.HelpText" Value="{Binding ., Converter={StaticResource SizeTypeToHelpTextConverter}}" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.Resources>
<DataTemplate DataType="{x:Type m:ResizeSize}">
<Grid VerticalAlignment="Center">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{Binding Name}" />
<StackPanel Grid.Row="1" Orientation="Horizontal">
<TextBlock Text="{Binding Fit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ThirdPersonSingular}" />
<TextBlock
Margin="4,0,0,0"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{Binding Width, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}" />
<TextBlock
Margin="4,0,0,0"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="×"
Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" />
<TextBlock
Margin="4,0,0,0"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{Binding Height, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}"
Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" />
<TextBlock Margin="4,0,0,0" Text="{Binding Unit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ToLower}" />
<StackPanel Grid.Row="0" Margin="16">
<ComboBox
Name="SizeComboBox"
Height="64"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.HelpText="{Binding Settings.SelectedSize, Converter={StaticResource SizeTypeToHelpTextConverter}}"
AutomationProperties.Name="{x:Static p:Resources.Image_Sizes}"
ItemsSource="{Binding Settings.AllSizes}"
SelectedItem="{Binding Settings.SelectedSize, Mode=TwoWay}">
<ComboBox.ItemContainerStyle>
<Style BasedOn="{StaticResource {x:Type ComboBoxItem}}" TargetType="ComboBoxItem">
<Setter Property="AutomationProperties.Name" Value="{Binding Name}" />
<Setter Property="AutomationProperties.HelpText" Value="{Binding ., Converter={StaticResource SizeTypeToHelpTextConverter}}" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.Resources>
<DataTemplate DataType="{x:Type m:ResizeSize}">
<Grid VerticalAlignment="Center">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{Binding Name}" />
<StackPanel Grid.Row="1" Orientation="Horizontal">
<TextBlock Text="{Binding Fit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ThirdPersonSingular}" />
<TextBlock
Margin="4,0,0,0"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{Binding Width, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}" />
<TextBlock
Margin="4,0,0,0"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="×"
Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" />
<TextBlock
Margin="4,0,0,0"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{Binding Height, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}"
Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" />
<TextBlock Margin="4,0,0,0" Text="{Binding Unit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ToLower}" />
</StackPanel>
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type m:CustomSize}">
<Grid VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type m:AiSize}">
<StackPanel VerticalAlignment="Center" Orientation="Vertical">
<TextBlock FontWeight="SemiBold" Text="{x:Static p:Resources.Input_AiSuperResolution}" />
<TextBlock Text="{x:Static p:Resources.Input_AiSuperResolutionDescription}" />
</StackPanel>
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type m:CustomSize}">
<Grid VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
</Grid>
</DataTemplate>
</ComboBox.Resources>
</ComboBox>
</DataTemplate>
</ComboBox.Resources>
</ComboBox>
</StackPanel>
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -84,6 +107,90 @@
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
BorderThickness="0,1,0,0" />
<!-- AI Configuration Panel -->
<Grid Margin="16">
<!-- AI Model Download Prompt -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding ShowModelDownloadPrompt}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<ui:InfoBar
IsClosable="False"
IsOpen="True"
Message="{Binding ModelStatusMessage}"
Severity="Informational" />
<ui:Button
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Appearance="Primary"
Command="{Binding DownloadModelCommand}"
Content="{x:Static p:Resources.Input_AiModelDownloadButton}"
Visibility="{Binding IsModelDownloading, Converter={StaticResource BoolValueConverter}, ConverterParameter=Inverted}" />
<StackPanel Margin="0,8,0,0" Visibility="{Binding IsModelDownloading, Converter={StaticResource BoolValueConverter}}">
<ui:ProgressRing IsIndeterminate="True" />
<TextBlock
Margin="0,8,0,0"
HorizontalAlignment="Center"
Text="{Binding ModelStatusMessage}" />
</StackPanel>
</StackPanel>
<!-- AI Scale Controls -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding ShowAiControls}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Grid>
<TextBlock Text="{x:Static p:Resources.Input_AiCurrentLabel}" />
<TextBlock HorizontalAlignment="Right" Text="{Binding AiScaleDisplay}" />
</Grid>
<Slider
Margin="0,8,0,0"
AutomationProperties.Name="{x:Static p:Resources.Input_AiScaleLabel}"
IsSelectionRangeEnabled="False"
IsSnapToTickEnabled="True"
Maximum="8"
Minimum="1"
TickFrequency="1"
TickPlacement="BottomRight"
Ticks="1,2,3,4,5,6,7,8"
Value="{Binding AiSuperResolutionScale, Mode=TwoWay}" />
<StackPanel Margin="0,16,0,0" Visibility="{Binding ShowAiSizeDescriptions, Converter={StaticResource BoolValueConverter}}">
<Grid>
<TextBlock Foreground="{DynamicResource TextFillColorSecondaryBrush}" Text="{x:Static p:Resources.Input_AiCurrentLabel}" />
<TextBlock
HorizontalAlignment="Right"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding CurrentResolutionDescription}" />
</Grid>
<Grid Margin="0,8,0,0">
<TextBlock Text="{x:Static p:Resources.Input_AiNewLabel}" />
<TextBlock HorizontalAlignment="Right" Text="{Binding NewResolutionDescription}" />
</Grid>
</StackPanel>
</StackPanel>
</Grid>
<!-- "Custom" input matrix -->
<Grid Margin="16" Visibility="{Binding ElementName=SizeComboBox, Path=SelectedValue, Converter={StaticResource SizeTypeToVisibilityConverter}}">
<Grid.ColumnDefinitions>
@@ -280,7 +387,8 @@
Appearance="Primary"
AutomationProperties.Name="{x:Static p:Resources.Resize_Tooltip}"
Command="{Binding ResizeCommand}"
IsDefault="True">
IsDefault="True"
Style="{StaticResource ReadableDisabledButtonStyle}">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon FontSize="16" Symbol="ResizeLarge16" />
<TextBlock Margin="8,0,0,0" Text="{x:Static p:Resources.Input_Resize}" />

11
src/runner/ai_detection.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
// Detect AI capabilities by calling ImageResizer in detection mode.
// This runs in a background thread to avoid blocking.
// ImageResizer writes the result to a cache file that it reads on normal startup.
//
// Parameters:
// skipSettingsCheck - If true, skip checking if ImageResizer is enabled in settings.
// Use this when called from apply_general_settings where we know
// ImageResizer is being enabled but settings file may not be saved yet.
void DetectAiCapabilitiesAsync(bool skipSettingsCheck = false);

View File

@@ -10,6 +10,7 @@
#include <common/themes/windows_colors.h>
#include "trace.h"
#include "ai_detection.h"
#include <common/utils/elevation.h>
#include <common/version/version.h>
#include <common/utils/resources.h>
@@ -279,6 +280,13 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save)
powertoy->enable();
auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance();
hkmng.EnableHotkeyByModule(name);
// Trigger AI capability detection when ImageResizer is enabled
if (name == L"Image Resizer")
{
Logger::info(L"ImageResizer enabled, triggering AI capability detection");
DetectAiCapabilitiesAsync(true); // Skip settings check since we know it's being enabled
}
}
else
{

View File

@@ -34,8 +34,11 @@
#include <Psapi.h>
#include <RestartManager.h>
#include <shellapi.h>
#include "centralized_kb_hook.h"
#include "centralized_hotkeys.h"
#include "ai_detection.h"
#include <common/utils/package.h>
#if _DEBUG && _WIN64
#include "unhandled_exception_handler.h"
@@ -76,6 +79,87 @@ void chdir_current_executable()
}
}
// Detect AI capabilities by calling ImageResizer in detection mode.
// This runs in a background thread to avoid blocking the main startup.
// ImageResizer writes the result to a cache file that it reads on normal startup.
void DetectAiCapabilitiesAsync(bool skipSettingsCheck)
{
std::thread([skipSettingsCheck]() {
try
{
// Check if ImageResizer module is enabled (skip if called from apply_general_settings)
if (!skipSettingsCheck)
{
auto settings = PTSettingsHelper::load_general_settings();
if (json::has(settings, L"enabled", json::JsonValueType::Object))
{
auto enabledModules = settings.GetNamedObject(L"enabled");
if (json::has(enabledModules, L"Image Resizer", json::JsonValueType::Boolean))
{
bool isEnabled = enabledModules.GetNamedBoolean(L"Image Resizer", false);
if (!isEnabled)
{
Logger::info(L"ImageResizer module is disabled, skipping AI detection");
return;
}
}
}
}
// Get ImageResizer.exe path (located in WinUI3Apps folder)
std::wstring imageResizerPath = get_module_folderpath();
imageResizerPath += L"\\WinUI3Apps\\PowerToys.ImageResizer.exe";
if (!std::filesystem::exists(imageResizerPath))
{
Logger::warn(L"ImageResizer.exe not found at {}, skipping AI detection", imageResizerPath);
return;
}
Logger::info(L"Starting AI capability detection via ImageResizer");
// Call ImageResizer --detect-ai
SHELLEXECUTEINFO sei = { sizeof(sei) };
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI;
sei.lpFile = imageResizerPath.c_str();
sei.lpParameters = L"--detect-ai";
sei.nShow = SW_HIDE;
if (ShellExecuteExW(&sei))
{
// Wait for detection to complete (with timeout)
DWORD waitResult = WaitForSingleObject(sei.hProcess, 30000); // 30 second timeout
CloseHandle(sei.hProcess);
if (waitResult == WAIT_OBJECT_0)
{
Logger::info(L"AI capability detection completed successfully");
}
else if (waitResult == WAIT_TIMEOUT)
{
Logger::warn(L"AI capability detection timed out");
}
else
{
Logger::warn(L"AI capability detection wait failed");
}
}
else
{
Logger::warn(L"Failed to launch ImageResizer for AI detection, error: {}", GetLastError());
}
}
catch (const std::exception& e)
{
Logger::error("Exception during AI capability detection: {}", e.what());
}
catch (...)
{
Logger::error("Unknown exception during AI capability detection");
}
}).detach();
}
inline wil::unique_mutex_nothrow create_msi_mutex()
{
return createAppMutex(POWERTOYS_MSI_MUTEX_NAME);
@@ -127,6 +211,18 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
PeriodicUpdateWorker();
} }.detach();
// Start AI capability detection in background (Windows 11+ only)
// AI Super Resolution is not supported on Windows 10
// This calls ImageResizer --detect-ai which writes result to cache file
if (package::IsWin11OrGreater())
{
DetectAiCapabilitiesAsync();
}
else
{
Logger::info(L"AI capability detection skipped: Windows 10 does not support AI Super Resolution");
}
std::thread{ [] {
if (updating::uninstall_previous_msix_version_async().get())
{

View File

@@ -78,6 +78,7 @@
</ItemGroup>
<ItemGroup>
<ClInclude Include="ActionRunnerUtils.h" />
<ClInclude Include="ai_detection.h" />
<ClInclude Include="auto_start_helper.h" />
<ClInclude Include="bug_report.h" />
<ClInclude Include="centralized_hotkeys.h" />

View File

@@ -81,6 +81,9 @@
<ClInclude Include="ActionRunnerUtils.h">
<Filter>Utils</Filter>
</ClInclude>
<ClInclude Include="ai_detection.h">
<Filter>Utils</Filter>
</ClInclude>
<ClInclude Include="resource.h">
<Filter>Utils</Filter>
</ClInclude>