Compare commits

...

22 Commits

Author SHA1 Message Date
Yu Leng
9c02956370 update 2025-11-05 16:05:54 +08:00
Yu Leng
6c4c29b5eb update 2025-11-05 15:40:47 +08:00
Yu Leng
8740ddacc4 update 2025-11-05 15:37:32 +08:00
Yu Leng
ac392893a9 update 2025-11-05 15:36:17 +08:00
Yu Leng
fb36d9965e update 2025-11-05 15:29:18 +08:00
Yu Leng
150e09bedb fix filename issue 2025-11-05 15:23:59 +08:00
Yu Leng
e221a4b80d init 2025-11-05 15:14:45 +08:00
Yu Leng
1e228341e3 Enable self-contained deployment for Windows App SDK in project configuration 2025-11-05 14:12:29 +08:00
Copilot
847a7bf0a8 Add clarifying comments to ValidateSelectedSizeIndex validation logic (#43296)
Code review feedback identified that the `ValidateSelectedSizeIndex()`
method lacked sufficient comments explaining the validation logic,
particularly around the index structure and when different maximum
indices apply.

## Changes

- Added comment documenting the index structure: `0..Count-1` (regular
sizes), `Count` (CustomSize), `Count+1` (AiSize)
- Clarified that `maxIndex = Sizes.Count + 1` allows up to AiSize when
AI is supported
- Enhanced existing comment to explicitly state it applies when AI is
not supported

```csharp
private void ValidateSelectedSizeIndex()
{
    // Index structure: 0 to Sizes.Count-1 (regular), Sizes.Count (CustomSize), Sizes.Count+1 (AiSize)
    var maxIndex = Sizes.Count + 1; // Allow up to AiSize when AI is supported
    if (_aiAvailabilityState == AiAvailabilityState.NotSupported)
    {
        maxIndex = Sizes.Count; // Only up to CustomSize when AI is not supported
    }

    if (_selectedSizeIndex > maxIndex)
    {
        _selectedSizeIndex = 0; // Reset to first size
    }
}
```

The validation logic itself was correct; this change improves code
clarity.

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: moooyo <42196638+moooyo@users.noreply.github.com>
2025-11-05 14:05:40 +08:00
Copilot
131e593c84 Include exception details when logging CultureNotFoundException (#43297)
Addresses review feedback from #42331 to include exception details in
logging for better debugging.

## Changes
- Capture `CultureNotFoundException` in catch block variable
- Log exception message using `LogError` instead of `LogWarning`
- Aligns with pattern used across FancyZones, ColorPicker, PowerOCR, and
other modules

```csharp
// Before
catch (CultureNotFoundException)
{
    Logger.LogWarning("CultureNotFoundException while setting UI culture.");
}

// After
catch (CultureNotFoundException ex)
{
    Logger.LogError("CultureNotFoundException: " + ex.Message);
}
```

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: moooyo <42196638+moooyo@users.noreply.github.com>
2025-11-05 14:04:58 +08:00
Copilot
291723aa28 Centralize default AI scale constant to eliminate hardcoded duplicate (#43298)
The default AI scale value `2` was hardcoded in `Settings.cs` despite
existing as a constant `DefaultAiScale` in `InputViewModel`. This
creates maintenance burden and risk of inconsistency.

## Changes

- Made `DefaultAiScale` constant public in `InputViewModel` for reuse
across classes
- Updated `Settings.cs` to reference `InputViewModel.DefaultAiScale`
instead of hardcoded `2`

```csharp
// Before
AiSize = jsonSettings.AiSize ?? new AiSize(2);

// After  
AiSize = jsonSettings.AiSize ?? new AiSize(InputViewModel.DefaultAiScale);
```

Default value now maintained in single location.

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: moooyo <42196638+moooyo@users.noreply.github.com>
2025-11-05 14:04:23 +08:00
moooyo
2b126b5b14 Update src/modules/imageresizer/ui/Views/InputPage.xaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 13:47:34 +08:00
moooyo
12d857f895 Update src/modules/imageresizer/ui/Models/ResizeOperation.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 13:45:56 +08:00
moooyo
7db4bf83d7 Update src/modules/imageresizer/ui/Services/WinAiSuperResolutionService.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 13:39:34 +08:00
Yu Leng
5632285383 update 2025-11-04 16:33:09 +08:00
Yu Leng
c80517b97b update 2025-11-04 15:28:53 +08:00
Yu Leng
6f1af6123d update 2025-11-04 13:45:07 +08:00
Yu Leng
36e383a5c8 update 2025-11-04 12:32:55 +08:00
Yu Leng
0b90d10507 Merge branch 'main' into yuleng/imageResizer/pr/1 2025-11-04 10:22:21 +08:00
Yu Leng (from Dev Box)
cceee12583 Fix spelling issue 2025-10-21 16:43:15 +08:00
Yu Leng (from Dev Box)
f56293763c merge main 2025-10-21 16:34:25 +08:00
Yu Leng (from Dev Box)
1c73f0c518 init 2025-10-13 16:35:55 +08:00
14 changed files with 1395 additions and 80 deletions

View File

@@ -8,7 +8,6 @@ using System;
using System.Globalization;
using System.Text;
using System.Windows;
using ImageResizer.Models;
using ImageResizer.Properties;
using ImageResizer.Utilities;
@@ -20,8 +19,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 +53,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;
@@ -48,23 +71,115 @@ namespace ImageResizer
/* 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;
}
// Check AI availability at startup (not relying on cached settings)
AiAvailabilityState = CheckAiAvailability();
Logger.LogInfo($"AI availability checked at startup: {AiAvailabilityState}");
// 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)
var mainWindow = new MainWindow(new MainViewModel(batch, Settings.Default));
mainWindow.Show();
Logger.LogInfo("MainWindow shown (unpackaged or activation fallback path).");
// Temporary workaround for issue #1273
WindowHelpers.BringToForeground(new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle);
}
/// <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 - fallback to NoOp
ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance);
Logger.LogWarning("AI Super Resolution service initialization failed. Using fallback.");
finalState = AiAvailabilityState.NotSupported;
}
}
catch (Exception ex)
{
// Log error and use NoOp service as fallback
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,6 +19,7 @@
<RootNamespace>ImageResizer</RootNamespace>
<AssemblyName>PowerToys.ImageResizer</AssemblyName>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>
@@ -46,6 +48,7 @@
<Resource Include="Resources\ImageResizer.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="WPF-UI" />

View File

@@ -0,0 +1,48 @@
// 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;
[JsonIgnore]
public override string Name
{
get => Resources.Input_AiSuperResolution;
set { /* no-op */ }
}
/// <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;
}
public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args)
{
var batch = new ResizeBatch();
@@ -119,6 +132,9 @@ namespace ImageResizer.Models
}
protected virtual void Execute(string file)
=> new ResizeOperation(file, DestinationDirectory, Settings.Default).Execute();
{
var aiService = _aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
new ResizeOperation(file, DestinationDirectory, Settings.Default, 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);
}
var originalWidth = source.PixelWidth;
var originalHeight = source.PixelHeight;
var width = _settings.SelectedSize.GetPixelWidth(originalWidth, source.DpiX);
@@ -217,6 +229,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.
@@ -323,19 +360,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

@@ -194,7 +194,187 @@ namespace ImageResizer.Properties {
return ResourceManager.GetString("Input_ShrinkOnly.Content", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to AI Super Resolution.
/// </summary>
public static string Input_AiSuperResolution {
get {
return ResourceManager.GetString("Input_AiSuperResolution", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Original size.
/// </summary>
public static string Input_AiOriginalSizeLabel {
get {
return ResourceManager.GetString("Input_AiOriginalSizeLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Enhanced size.
/// </summary>
public static string Input_AiEnhancedSizeLabel {
get {
return ResourceManager.GetString("Input_AiEnhancedSizeLabel", 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 Multiple files selected.
/// </summary>
public static string Input_AiMultipleFiles {
get {
return ResourceManager.GetString("Input_AiMultipleFiles", 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 Upscale to {0} times the original size..
/// </summary>
public static string Input_AiScaleHelp {
get {
return ResourceManager.GetString("Input_AiScaleHelp", 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 Current.
/// </summary>
public static string Input_AiCurrentLabel {
get {
return ResourceManager.GetString("Input_AiCurrentLabel", 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 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 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 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 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 Downloading AI model...
/// </summary>
public static string Input_AiModelDownloading {
get {
return ResourceManager.GetString("Input_AiModelDownloading", 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 Download AI Model.
/// </summary>
public static string Input_AiModelDownloadButton {
get {
return ResourceManager.GetString("Input_AiModelDownloadButton", 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 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 scaling operation failed..
/// </summary>
public static string Error_AiScalingFailed {
get {
return ResourceManager.GetString("Error_AiScalingFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Large.
/// </summary>

View File

@@ -296,4 +296,52 @@
<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>AI 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 AI Model</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>
</root>

View File

@@ -19,10 +19,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();
@@ -50,6 +62,7 @@ namespace ImageResizer.Properties
private bool _keepDateModified;
private System.Guid _fallbackEncoder;
private CustomSize _customSize;
private AiSize _aiSize;
public Settings()
{
@@ -72,9 +85,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; }
@@ -94,15 +126,35 @@ 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
{
return AiSize;
}
}
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;
@@ -138,13 +190,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;
@@ -163,6 +219,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;
@@ -185,12 +253,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);
@@ -410,6 +496,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;
@@ -475,6 +573,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)
@@ -485,6 +584,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();
});
_jsonMutex.ReleaseMutex();

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,116 @@ 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 AiScaleDescription => FormatLabeledSize(Resources.Input_AiScaleLabel, AiScaleDisplay);
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 +183,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 +215,242 @@ 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;
var currentValue = hasConcreteSize
? FormatDimensions(_originalWidth!.Value, _originalHeight!.Value)
: Resources.Input_AiUnknownSize;
CurrentResolutionDescription = FormatLabeledSize(Resources.Input_AiCurrentLabel, currentValue);
var scale = Settings.AiSize.Scale;
var newValue = hasConcreteSize
? FormatDimensions((long)_originalWidth!.Value * scale, (long)_originalHeight!.Value * scale)
: Resources.Input_AiUnknownSize;
NewResolutionDescription = FormatLabeledSize(Resources.Input_AiNewLabel, newValue);
}
private static string FormatDimensions(long width, long height)
{
return string.Format(CultureInfo.CurrentCulture, "{0} × {1}", width, height);
}
private static string FormatLabeledSize(string label, string value)
{
return string.Format(CultureInfo.CurrentCulture, "{0}: {1}", label, value);
}
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));
OnPropertyChanged(nameof(AiScaleDescription));
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,60 +32,140 @@
<!-- 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>
</DataTemplate>
<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>
</ComboBox.Resources>
</ComboBox>
<DataTemplate DataType="{x:Type m:CustomSize}">
<Grid VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type m:AiSize}">
<Grid VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
</Grid>
</DataTemplate>
</ComboBox.Resources>
</ComboBox>
<!-- AI Configuration Panel -->
<Grid Margin="0,8,0,0">
<!-- 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>
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{Binding AiScaleDescription}" />
<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,8,0,0" Visibility="{Binding ShowAiSizeDescriptions, Converter={StaticResource BoolValueConverter}}">
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="{Binding CurrentResolutionDescription}" />
<TextBlock
Margin="0,4,0,0"
Style="{StaticResource BodyTextBlockStyle}"
Text="{Binding NewResolutionDescription}" />
</StackPanel>
</StackPanel>
</Grid>
</StackPanel>
<Grid Grid.Row="1">
<Grid.RowDefinitions>
@@ -280,7 +377,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}" />