Compare commits

...

3 commits

Author SHA1 Message Date
25fdb82d2f feat: Add face encoder settings including GPU support, parallelism, and thumbnail options
Some checks failed
Build Windows Avalonia / build (push) Failing after 1m19s
Build Windows Avalonia / release (push) Has been skipped
2026-05-09 15:46:41 +02:00
d6b778a648 Refactor application to remove Windows Forms dependencies and transition to Avalonia UI
- Deleted MainWindow.xaml.cs, which contained the WPF implementation of the main window.
- Updated Program.cs to remove Windows Forms initialization and support only Avalonia UI.
- Removed Windows Forms specific code from ViewModelBase, including control marshalling logic.
2026-05-09 14:04:21 +02:00
988a3d94e1 feat: Implement face encoder functionality with GPU support and recursive option 2026-05-09 12:09:05 +02:00
26 changed files with 1338 additions and 4659 deletions

View file

@ -14,15 +14,15 @@ env:
PROJECT_PATH: imagecatalog/ImageCatalog 2.csproj
PUBLISH_DIR: artifacts/publish/win-x64
ARTIFACT_NAME: imagecatalog-windows-avalonia
NUGET_SOURCE_NAME: Nuget-GitLab-AIFotoONLUS
NUGET_SOURCE_URL: https://gitlab.com/api/v4/projects/79509532/packages/nuget/index.json
NUGET_SOURCE_NAME: Nuget-Forgejo-AIFotoONLUS
NUGET_SOURCE_URL: ${{ vars.AIFOTOONLUS_NUGET_SOURCE_URL || format('{0}/api/packages/{1}/nuget/index.json', github.server_url, vars.AIFOTOONLUS_PACKAGE_OWNER || github.repository_owner) }}
jobs:
build:
runs-on: docker
env:
NUGET_USERNAME: ${{ secrets.NUGET_USERNAME }}
NUGET_PASSWORD: ${{ secrets.NUGET_PASSWORD }}
FORGEJO_PACKAGE_USERNAME: ${{ secrets.FORGEJO_PACKAGE_USERNAME }}
FORGEJO_PACKAGE_TOKEN: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
steps:
- name: Checkout
@ -36,12 +36,12 @@ jobs:
- name: Validate NuGet secrets
run: |
set -eu
if [ -z "${NUGET_USERNAME}" ]; then
echo "secrets.NUGET_USERNAME is required"
if [ -z "${FORGEJO_PACKAGE_USERNAME}" ]; then
echo "secrets.FORGEJO_PACKAGE_USERNAME is required"
exit 1
fi
if [ -z "${NUGET_PASSWORD}" ]; then
echo "secrets.NUGET_PASSWORD is required"
if [ -z "${FORGEJO_PACKAGE_TOKEN}" ]; then
echo "secrets.FORGEJO_PACKAGE_TOKEN is required"
exit 1
fi
@ -53,8 +53,8 @@ jobs:
dotnet nuget update source "${{ env.NUGET_SOURCE_NAME }}" \
--source "${{ env.NUGET_SOURCE_URL }}" \
--username "${NUGET_USERNAME}" \
--password "${NUGET_PASSWORD}" \
--username "${FORGEJO_PACKAGE_USERNAME}" \
--password "${FORGEJO_PACKAGE_TOKEN}" \
--store-password-in-clear-text \
--configfile "${temp_config}"

View file

@ -4,7 +4,7 @@
```powershell
# Build
dotnet build Catalog.sln
dotnet build Catalog.slnx
# Run all tests
dotnet test MaddoShared.Tests
@ -21,13 +21,13 @@ dotnet publish "imagecatalog\ImageCatalog 2.csproj" -c Release -r win-x64 --self
## Architecture
This is a WinForms/WPF image cataloging application targeting .NET 10.0-windows.
This is an Avalonia image cataloging application targeting .NET 10.0-windows.
### Projects
| Project | Purpose |
|---------|---------|
| **imagecatalog** | Main desktop application — WinForms (default), WPF (`--wpf`), or Avalonia (`--avalonia`) |
| **imagecatalog** | Main desktop application — Avalonia with Fluent theme (`AvaloniaMainWindow`) |
| **MaddoShared** | Shared image processing library (the core) |
| **MaddoShared.Tests** | Unit tests for MaddoShared |
| **MaddoShared.Benchmarks** | BenchmarkDotNet performance benchmarks |
@ -35,12 +35,7 @@ This is a WinForms/WPF image cataloging application targeting .NET 10.0-windows.
| **ImageCatalogCS / ImageCatalogParallel** | Legacy/experimental variants |
| **CatalogLib / CatalogLibVb / CatalogVbLib** | Legacy VB.NET libraries |
The main app selects its UI at startup via command-line flag:
- *(default)* — WinForms (`MainForm`)
- `--wpf` — WPF with MahApps.Metro (`MainWindow`)
- `--avalonia` — Avalonia with Fluent theme (`AvaloniaMainWindow`)
All three UIs bind to the same `DataModel`. Dialog events (`SelectSourceFolderRequested`, etc.) are subscribed in each window's code-behind. `DataModel.UiInvoker` must be set by the active UI to enable cross-thread UI updates (Avalonia sets this to `Dispatcher.UIThread.Invoke`; WPF uses `Application.Current.Dispatcher`).
The main app launches Avalonia directly. Dialog events (`SelectSourceFolderRequested`, etc.) are subscribed in `AvaloniaMainWindow` code-behind. `DataModel.UiInvoker` must be set by the active UI to enable cross-thread UI updates (Avalonia sets this to `Dispatcher.UIThread.Invoke`).
### Core Flow

16
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "ImageCatalog Avalonia",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build ImageCatalog Avalonia",
"program": "${workspaceFolder:Catalog}/imagecatalog/bin/Debug/net10.0-windows/win-x64/ImageCatalog.exe",
"args": [],
"cwd": "${workspaceFolder:Catalog}/imagecatalog",
"stopAtEntry": false,
"console": "internalConsole"
}
]
}

18
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build ImageCatalog Avalonia",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder:Catalog}/imagecatalog/ImageCatalog 2.csproj",
"--configuration",
"Debug"
],
"problemMatcher": "$msCompile",
"group": "build"
}
]
}

16
Catalog.code-workspace Normal file
View file

@ -0,0 +1,16 @@
{
"folders": [
{
"path": "."
},
{
"path": "../AIFotoONLUS"
},
{
"path": "../../various/regalamiunsorriso"
}
],
"settings": {
"commentTranslate.hover.enabled": false
}
}

View file

@ -1,116 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.2.11415.280
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageCatalog 2", "imagecatalog\ImageCatalog 2.csproj", "{3F1E23DB-435E-0590-1EF5-735E898DBA3C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libraries", "Libraries", "{A3D50937-74F6-4DC8-8D89-B534B484C0F9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaddoShared", "MaddoShared\MaddoShared.csproj", "{AEBFE9E3-277C-4A7B-8448-145D1B11998B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{5F0BEF23-B1EA-4100-A772-DC455D40B1C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Tests", "MaddoShared.Tests\MaddoShared.Tests.csproj", "{59952BE8-20B4-4BF2-9367-705F41395265}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Benchmarks", "MaddoShared.Benchmarks\MaddoShared.Benchmarks.csproj", "{07499348-8C15-4DCC-8316-4AD121A43C38}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Catalog.Communication", "Catalog.Communication\Catalog.Communication.csproj", "{EF5D3B7E-F380-4976-A0A9-085FEA157F79}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.ImageSharpTests", "MaddoShared.ImageSharpTests\MaddoShared.ImageSharpTests.csproj", "{1528903F-3BF9-599C-2DD0-0AF7B5706675}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Debug|x64.ActiveCfg = Debug|Any CPU
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Debug|x64.Build.0 = Debug|Any CPU
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Debug|x86.ActiveCfg = Debug|x86
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Debug|x86.Build.0 = Debug|x86
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Release|Any CPU.Build.0 = Release|Any CPU
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Release|x64.ActiveCfg = Release|x64
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Release|x64.Build.0 = Release|x64
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Release|x86.ActiveCfg = Release|x86
{3F1E23DB-435E-0590-1EF5-735E898DBA3C}.Release|x86.Build.0 = Release|x86
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Debug|x64.ActiveCfg = Debug|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Debug|x64.Build.0 = Debug|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Debug|x86.ActiveCfg = Debug|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Debug|x86.Build.0 = Debug|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Release|Any CPU.Build.0 = Release|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Release|x64.ActiveCfg = Release|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Release|x64.Build.0 = Release|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Release|x86.ActiveCfg = Release|Any CPU
{AEBFE9E3-277C-4A7B-8448-145D1B11998B}.Release|x86.Build.0 = Release|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Debug|x64.ActiveCfg = Debug|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Debug|x64.Build.0 = Debug|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Debug|x86.ActiveCfg = Debug|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Debug|x86.Build.0 = Debug|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Release|Any CPU.Build.0 = Release|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Release|x64.ActiveCfg = Release|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Release|x64.Build.0 = Release|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Release|x86.ActiveCfg = Release|Any CPU
{59952BE8-20B4-4BF2-9367-705F41395265}.Release|x86.Build.0 = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Debug|x64.ActiveCfg = Debug|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Debug|x64.Build.0 = Debug|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Debug|x86.ActiveCfg = Debug|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Debug|x86.Build.0 = Debug|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|Any CPU.ActiveCfg = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|Any CPU.Build.0 = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x64.ActiveCfg = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x64.Build.0 = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.ActiveCfg = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.Build.0 = Release|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x64.ActiveCfg = Debug|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x64.Build.0 = Debug|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x86.ActiveCfg = Debug|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x86.Build.0 = Debug|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|Any CPU.Build.0 = Release|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x64.ActiveCfg = Release|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x64.Build.0 = Release|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x86.ActiveCfg = Release|Any CPU
{EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x86.Build.0 = Release|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Debug|x64.ActiveCfg = Debug|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Debug|x64.Build.0 = Debug|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Debug|x86.ActiveCfg = Debug|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Debug|x86.Build.0 = Debug|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Release|Any CPU.Build.0 = Release|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Release|x64.ActiveCfg = Release|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Release|x64.Build.0 = Release|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Release|x86.ActiveCfg = Release|Any CPU
{1528903F-3BF9-599C-2DD0-0AF7B5706675}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{AEBFE9E3-277C-4A7B-8448-145D1B11998B} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9}
{59952BE8-20B4-4BF2-9367-705F41395265} = {5F0BEF23-B1EA-4100-A772-DC455D40B1C1}
{EF5D3B7E-F380-4976-A0A9-085FEA157F79} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0E3ABC63-8601-4DAC-AFEA-33F3E8E36757}
EndGlobalSection
EndGlobal

20
Catalog.slnx Normal file
View file

@ -0,0 +1,20 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/Libraries/">
<Project Path="Catalog.Communication/Catalog.Communication.csproj" />
<Project Path="MaddoShared/MaddoShared.csproj" />
</Folder>
<Folder Name="/Tests/">
<Project Path="MaddoShared.Tests/MaddoShared.Tests.csproj" />
</Folder>
<Project Path="imagecatalog/ImageCatalog 2.csproj">
<Platform Solution="*|x86" Project="x86" />
<Platform Solution="Release|x64" Project="x64" />
</Project>
<Project Path="MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj" />
<Project Path="MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj" />
</Solution>

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Threading.Tasks;
using ImageCatalog_2;
using ImageCatalog_2.Services;
@ -134,6 +135,61 @@ public class DataModelCharacterizationTests
model.FontSize.ShouldBe(42);
}
[TestMethod]
public void FaceExecutableFolder_EnablesGpuToggleWhenBothVariantsExist()
{
using var root = new TemporaryDirectory();
CreateFaceEncoderExecutable(root.Path, "cpu");
CreateFaceEncoderExecutable(root.Path, "gpu");
var model = CreateModel();
model.FaceExecutablePath = root.Path;
model.FaceGpuOptionEnabled.ShouldBeTrue();
model.UseFaceGpu.ShouldBeFalse();
}
[TestMethod]
public void UseFaceGpu_UpdatesUpsampleWhenUsingRecommendedDefault()
{
using var root = new TemporaryDirectory();
CreateFaceEncoderExecutable(root.Path, "cpu");
CreateFaceEncoderExecutable(root.Path, "gpu");
var model = CreateModel();
model.FaceExecutablePath = root.Path;
model.FaceUpsample.ShouldBeTrue();
model.UseFaceGpu = true;
model.FaceUpsample.ShouldBeFalse();
}
[TestMethod]
public void ResolveConfiguredFaceEncoderExecutablePath_UsesFolderLayoutFromPowerShellScript()
{
using var root = new TemporaryDirectory();
var cpuExecutable = CreateFaceEncoderExecutable(root.Path, "cpu");
var gpuExecutable = CreateFaceEncoderExecutable(root.Path, "gpu");
DataModel.ResolveConfiguredFaceEncoderExecutablePath(root.Path, useGpu: false).ShouldBe(cpuExecutable);
DataModel.ResolveConfiguredFaceEncoderExecutablePath(root.Path, useGpu: true).ShouldBe(gpuExecutable);
}
[TestMethod]
public void BuildFaceEncoderOutputPaths_UsesTimestampAndSanitizedFolderName()
{
var timestamp = new DateTime(2026, 5, 9, 14, 30, 45);
var output = DataModel.BuildFaceEncoderOutputPaths(
@"C:\out",
@"C:\images\04 APRILE: gara?",
timestamp);
output.OutputFilePath.ShouldBe(@"C:\out\face_encodings_20260509_143045_04_APRILE_gara.pkl");
output.LogFilePath.ShouldBe(@"C:\out\encoder_log_20260509_143045_04_APRILE_gara.txt");
}
private static DataModel CreateModel(
ISettingsService? settingsService = null,
ITestService? testService = null)
@ -169,4 +225,33 @@ public class DataModelCharacterizationTests
Substitute.For<ILogger<DataModel>>(),
versionProvider: null);
}
private static string CreateFaceEncoderExecutable(string rootPath, string variant)
{
var variantDirectory = Path.Combine(rootPath, $"face_encoder_{variant}");
Directory.CreateDirectory(variantDirectory);
var executablePath = Path.Combine(variantDirectory, $"face_encoder_{variant}.exe");
File.WriteAllText(executablePath, "stub");
return executablePath;
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName());
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View file

@ -17,7 +17,7 @@
<ItemGroup>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="System.Drawing.Common" Version="10.0.3" />
</ItemGroup>

View file

@ -1,25 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Repository-level NuGet.Config to control which sources are queried for which
packages. This prevents the GitLab feed from being queried for all package
packages. This prevents the Forgejo feed from being queried for all package
IDs (which causes 401 errors for packages hosted on nuget.org).
Usage:
- Keep this file in the repository root so `dotnet restore` picks it up by default.
- CI still needs to add the `GitLab` source credentials at runtime (we do this
- CI still needs to add the `Forgejo` source credentials at runtime (we do this
from the pipeline using `dotnet nuget add source ...`), but the mapping below
ensures only the listed package ID patterns are requested from the GitLab feed.
ensures only the listed package ID patterns are requested from the Forgejo feed.
-->
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="Nuget-GitLab-AIFotoONLUS" value="https://gitlab.com/api/v4/projects/79509532/packages/nuget/index.json" />
<add key="Nuget-Forgejo-AIFotoONLUS" value="https://forgejo.maddoscientisto.net/api/packages/maddo/nuget/index.json" />
</packageSources>
<!-- Map private package IDs to the GitLab source; everything else uses nuget.org -->
<!-- Map private package IDs to the Forgejo source; everything else uses nuget.org -->
<packageSourceMapping>
<packageSource key="Nuget-GitLab-AIFotoONLUS">
<!-- Add patterns for your private packages hosted in GitLab -->
<packageSource key="Nuget-Forgejo-AIFotoONLUS">
<!-- Add patterns for your private packages hosted in Forgejo -->
<package pattern="AIFotoONLUS.*" />
<package pattern="AIFotoONLUS.Core" />
</packageSource>

View file

@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using System.ComponentModel;
using System.IO;
namespace ImageCatalog_2;
@ -20,6 +21,7 @@ public partial class AvaloniaMainWindow : Window
DataContext = _model;
Opened += (_, _) => SyncThemeStateFromCurrentTheme();
Closing += AvaloniaMainWindow_Closing;
// Let DataModel marshal callbacks onto Avalonia UI thread.
_model.UiInvoker = action => Dispatcher.UIThread.Invoke(action);
@ -135,6 +137,29 @@ public partial class AvaloniaMainWindow : Window
};
}
private bool _isStoppingFaceEncoderForClose;
private async void AvaloniaMainWindow_Closing(object? sender, CancelEventArgs e)
{
if (_isStoppingFaceEncoderForClose || !_model.IsFaceEncoderRunning)
{
return;
}
e.Cancel = true;
_isStoppingFaceEncoderForClose = true;
try
{
await _model.StopFaceEncoderAsync("Arresto face encoder in chiusura...", waitForExit: true);
}
finally
{
_isStoppingFaceEncoderForClose = false;
Close();
}
}
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
{
_isDarkTheme = !_isDarkTheme;

View file

@ -5,16 +5,16 @@
<ScrollViewer>
<StackPanel Margin="4" Spacing="6">
<TextBlock Text="Face Recognition Encoder" FontWeight="Bold" />
<TextBlock Text="Esegue face_encoder.exe usando la cartella Destinazione corrente come --images e un file .pkl come --out."
<TextBlock Text="Esegue il face encoder usando la cartella Destinazione corrente come --images e genera automaticamente file .pkl e log nella cartella di output scelta."
TextWrapping="Wrap" Opacity="0.8" />
<TextBlock Text="Eseguibile" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Text="Cartella Face Encoder" FontWeight="Bold" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="face_encoder:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceExecutablePathTextBox" Text="{Binding FaceExecutablePath, Mode=TwoWay}" Watermark="C:\\tools\\face_encoder.exe" />
<TextBlock Grid.Column="0" Text="Percorso:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceExecutablePathTextBox" Text="{Binding FaceExecutablePath, Mode=TwoWay}" Watermark="C:\tools\Face_Recognition_Windows" />
<Button Grid.Column="2" Name="FaceSelectExecutableButton" Click="SelectFaceExecutable_Click" Width="104" Margin="6,0,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6">
<iconPacks:PackIconMaterial Kind="FileOutline" Width="14" Height="14" />
<iconPacks:PackIconMaterial Kind="FolderOutline" Width="14" Height="14" />
<TextBlock Text="Scegli..." />
</StackPanel>
</Button>
@ -26,6 +26,26 @@
</Button>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="12">
<CheckBox Content="Ricorsivo (--recursive)" IsChecked="{Binding FaceRecursive, Mode=TwoWay}" />
<CheckBox Content="Includi thumbnail (--include-tn)" IsChecked="{Binding FaceIncludeThumbnails, Mode=TwoWay}" />
<CheckBox Content="Upsample (--upsample)" IsChecked="{Binding FaceUpsample, Mode=TwoWay}" />
<CheckBox Content="Usa GPU"
IsChecked="{Binding UseFaceGpu, Mode=TwoWay}"
IsEnabled="{Binding FaceGpuOptionEnabled}" />
</StackPanel>
<TextBlock Text="Seleziona la cartella base di Face Recognition Windows: l'app sceglie automaticamente face_encoder_cpu.exe o face_encoder_gpu.exe in base al checkbox GPU."
TextWrapping="Wrap"
Opacity="0.75" />
<Grid ColumnDefinitions="Auto,120,Auto,120,*" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Parallelismo:" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" ItemsSource="{Binding FaceParallelismOptions}" SelectedItem="{Binding FaceParallelism, Mode=TwoWay}" />
<TextBlock Grid.Column="2" Text="Min size:" VerticalAlignment="Center" />
<TextBox Grid.Column="3" Text="{Binding FaceMinSize, Mode=TwoWay}" Watermark="35" />
<TextBlock Grid.Column="4" Text="Usa --multicore in CPU e --multiprocess in GPU." VerticalAlignment="Center" Opacity="0.75" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceDestinationPathTextBox" Text="{Binding DestinationPath, Mode=OneWay}" IsReadOnly="True" />
@ -39,11 +59,11 @@
<TextBlock Text="Output encodings" FontWeight="Bold" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="File out (.pkl):" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceOutputFolderTextBox" Text="{Binding FaceOutputFolderPath, Mode=TwoWay}" Watermark="C:\\output\\encodings.pkl" />
<Button Grid.Column="2" Name="FaceSelectOutputButton" Click="SelectFaceOutputFile_Click" Width="104" Margin="6,0,0,0">
<TextBlock Grid.Column="0" Text="Cartella out:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceOutputFolderTextBox" Text="{Binding FaceOutputFolderPath, Mode=TwoWay}" Watermark="C:\output\face_encoder" />
<Button Grid.Column="2" Name="FaceSelectOutputButton" Click="SelectFaceOutputFolder_Click" Width="104" Margin="6,0,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6">
<iconPacks:PackIconMaterial Kind="FileOutline" Width="14" Height="14" />
<iconPacks:PackIconMaterial Kind="FolderOutline" Width="14" Height="14" />
<TextBlock Text="Scegli..." />
</StackPanel>
</Button>
@ -54,18 +74,36 @@
</StackPanel>
</Button>
</Grid>
<TextBlock Text="I file vengono creati come face_encodings_yyyyMMdd_HHmmss_nomecartella.pkl e encoder_log_yyyyMMdd_HHmmss_nomecartella.txt."
TextWrapping="Wrap"
Opacity="0.75" />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
<Button Name="FaceRunButton" Content="Esegui Face Encoder" Click="RunFaceEncoder_Click" />
<TextBlock Name="FaceStatusTextBlock" VerticalAlignment="Center" />
<Button Name="FaceRunButton" Command="{Binding StartFaceEncoderCommand}">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ProgressBar Width="18"
Height="18"
IsIndeterminate="True"
IsVisible="{Binding IsFaceEncoderRunning}"
ShowProgressText="False"
VerticalAlignment="Center" />
<TextBlock Text="Esegui Face Encoder" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Content="Stop" Command="{Binding StopFaceEncoderCommand}" />
<TextBlock VerticalAlignment="Center" Text="{Binding FaceStatusMessage}" />
</StackPanel>
<TextBlock Text="Output comando" FontWeight="Bold" Margin="0,6,0,0" />
<TextBox Name="FaceOutputTextBox"
Text="{Binding FaceCommandOutput}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="180" />
FontFamily="Cascadia Mono, Consolas, monospace"
Height="220"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Auto" />
</StackPanel>
</ScrollViewer>
</UserControl>

View file

@ -1,25 +1,56 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Avalonia.Threading;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace ImageCatalog_2.AvaloniaViews;
public partial class FaceAiTabView : Avalonia.Controls.UserControl
{
private readonly ILogger<FaceAiTabView> _logger;
private INotifyPropertyChanged? _faceAiPropertySource;
public FaceAiTabView()
{
InitializeComponent();
_logger = Program.ServiceProvider.GetService(typeof(ILogger<FaceAiTabView>)) as ILogger<FaceAiTabView>
?? NullLogger<FaceAiTabView>.Instance;
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_faceAiPropertySource is not null)
{
_faceAiPropertySource.PropertyChanged -= OnFaceAiPropertyChanged;
}
_faceAiPropertySource = DataContext as INotifyPropertyChanged;
if (_faceAiPropertySource is not null)
{
_faceAiPropertySource.PropertyChanged += OnFaceAiPropertyChanged;
}
}
private void OnFaceAiPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (!string.Equals(e.PropertyName, nameof(DataModel.FaceCommandOutput), StringComparison.Ordinal))
{
return;
}
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputTextBox");
if (outputBox is null)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
var textLength = outputBox.Text?.Length ?? 0;
outputBox.CaretIndex = textLength;
});
}
private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e)
@ -37,19 +68,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Seleziona face_encoder.exe",
FileTypeFilter =
[
new FilePickerFileType("Eseguibile") { Patterns = ["*.exe"] },
new FilePickerFileType("Tutti i file") { Patterns = ["*.*"] }
]
Title = "Seleziona la cartella Face Recognition Windows"
});
if (files.Count > 0)
if (folders.Count > 0)
{
executableBox.Text = files[0].Path.LocalPath;
executableBox.Text = folders[0].Path.LocalPath;
if (DataContext is DataModel model)
{
model.FaceExecutablePath = executableBox.Text;
@ -57,7 +83,7 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
}
}
private async void SelectFaceOutputFile_Click(object? sender, RoutedEventArgs e)
private async void SelectFaceOutputFolder_Click(object? sender, RoutedEventArgs e)
{
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputFolderTextBox");
if (outputBox is null)
@ -72,21 +98,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
var files = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Seleziona file output encodings (.pkl)",
SuggestedFileName = "encodings.pkl",
DefaultExtension = "pkl",
FileTypeChoices =
[
new FilePickerFileType("Pickle file") { Patterns = ["*.pkl"] }
],
ShowOverwritePrompt = true
Title = "Seleziona la cartella output per encodings e log"
});
if (files is not null)
if (folders.Count > 0)
{
outputBox.Text = files.Path.LocalPath;
outputBox.Text = folders[0].Path.LocalPath;
if (DataContext is DataModel model)
{
model.FaceOutputFolderPath = outputBox.Text;
@ -108,6 +127,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
if (Directory.Exists(path))
{
OpenInExplorer(path);
return;
}
if (File.Exists(path))
{
OpenInExplorer(path);
@ -132,6 +157,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
if (Directory.Exists(outputPath))
{
OpenInExplorer(outputPath);
return;
}
if (File.Exists(outputPath))
{
OpenInExplorer(outputPath);
@ -156,171 +187,16 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
if (Directory.Exists(path))
{
OpenInExplorer(path);
return;
}
var directory = Path.GetDirectoryName(path);
OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory);
}
private async void RunFaceEncoder_Click(object? sender, RoutedEventArgs e)
{
var executableBox = this.FindControl<Avalonia.Controls.TextBox>("FaceExecutablePathTextBox");
var outputFolderBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputFolderTextBox");
var outputLogBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputTextBox");
var statusBlock = this.FindControl<TextBlock>("FaceStatusTextBlock");
var runButton = this.FindControl<Avalonia.Controls.Button>("FaceRunButton");
if (executableBox is null || outputFolderBox is null || outputLogBox is null || statusBlock is null || runButton is null)
{
return;
}
if (DataContext is not DataModel model)
{
statusBlock.Text = "DataContext non valido.";
return;
}
var executablePath = executableBox.Text?.Trim().Trim('"') ?? string.Empty;
var outputFilePath = outputFolderBox.Text?.Trim().Trim('"') ?? string.Empty;
var imagesFolder = (model.DestinationPath ?? string.Empty).Trim().Trim('"');
model.FaceExecutablePath = executablePath;
model.FaceOutputFolderPath = outputFilePath;
if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath))
{
statusBlock.Text = "Percorso eseguibile non valido.";
return;
}
if (string.IsNullOrWhiteSpace(imagesFolder) || !Directory.Exists(imagesFolder))
{
statusBlock.Text = "Cartella Destinazione non valida.";
return;
}
if (string.IsNullOrWhiteSpace(outputFilePath))
{
statusBlock.Text = "Inserisci il file di output .pkl.";
return;
}
if (!string.Equals(Path.GetExtension(outputFilePath), ".pkl", StringComparison.OrdinalIgnoreCase))
{
statusBlock.Text = "Il file di output deve avere estensione .pkl.";
return;
}
try
{
var outputDirectory = Path.GetDirectoryName(outputFilePath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to create face output directory for file: {OutputFilePath}", outputFilePath);
statusBlock.Text = "Impossibile creare la cartella del file di output.";
return;
}
runButton.IsEnabled = false;
statusBlock.Text = "Esecuzione face encoder in corso...";
outputLogBox.Text = string.Empty;
var outputLines = new StringBuilder();
var errorLines = new StringBuilder();
try
{
var imagesFolderArg = NormalizeDirectoryPathArgument(imagesFolder);
var outputFileArg = NormalizeFilePathArgument(outputFilePath);
var processStartInfo = new ProcessStartInfo
{
FileName = executablePath,
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
processStartInfo.ArgumentList.Add("--images");
processStartInfo.ArgumentList.Add(imagesFolderArg);
processStartInfo.ArgumentList.Add("--out");
processStartInfo.ArgumentList.Add(outputFileArg);
using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, args) =>
{
if (string.IsNullOrWhiteSpace(args.Data))
{
return;
}
lock (outputLines)
{
outputLines.AppendLine(args.Data);
}
};
process.ErrorDataReceived += (_, args) =>
{
if (string.IsNullOrWhiteSpace(args.Data))
{
return;
}
lock (errorLines)
{
errorLines.AppendLine(args.Data);
}
};
if (!process.Start())
{
throw new InvalidOperationException("Avvio face_encoder.exe fallito.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync().ConfigureAwait(true);
var summary = new StringBuilder();
summary.AppendLine($"Exit code: {process.ExitCode}");
if (outputLines.Length > 0)
{
summary.AppendLine();
summary.AppendLine("STDOUT:");
summary.Append(outputLines);
}
if (errorLines.Length > 0)
{
summary.AppendLine();
summary.AppendLine("STDERR:");
summary.Append(errorLines);
}
outputLogBox.Text = summary.ToString();
statusBlock.Text = process.ExitCode == 0
? "Face encoder completato."
: $"Face encoder terminato con errore (code {process.ExitCode}).";
}
catch (Exception ex)
{
_logger.LogError(ex, "Face encoder execution failed.");
outputLogBox.Text = ex.ToString();
statusBlock.Text = "Errore durante esecuzione face encoder.";
}
finally
{
runButton.IsEnabled = true;
}
}
private static void OpenInExplorer(string? path)
{
if (string.IsNullOrWhiteSpace(path))
@ -345,31 +221,4 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
// Ignore failures when opening Explorer.
}
}
private static string NormalizeDirectoryPathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Trim('"');
var root = Path.GetPathRoot(normalized);
if (!string.IsNullOrEmpty(root) && normalized.Length > root.Length)
{
normalized = normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return normalized;
}
private static string NormalizeFilePathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Trim('"');
}
}

View file

@ -1,53 +0,0 @@
using System;
using System.Windows.Forms;
using System.Windows.Input;
namespace ImageCatalog_2.Commands
{
/// <summary>
/// Helper class to bind WinForms Button controls to ICommand implementations
/// </summary>
public static class ButtonCommandBinder
{
/// <summary>
/// Binds a Button's Click event to an ICommand
/// </summary>
/// <param name="button">The button to bind</param>
/// <param name="command">The command to execute</param>
/// <param name="commandParameter">Optional parameter to pass to the command</param>
public static void BindCommand(this Button button, ICommand command, object commandParameter = null)
{
if (button == null) throw new ArgumentNullException(nameof(button));
if (command == null) throw new ArgumentNullException(nameof(command));
// Wire up the Click event to execute the command
button.Click += (sender, e) =>
{
if (command.CanExecute(commandParameter))
{
command.Execute(commandParameter);
}
};
// Wire up CanExecuteChanged to enable/disable the button
command.CanExecuteChanged += (sender, e) =>
{
button.Enabled = command.CanExecute(commandParameter);
};
// Set initial enabled state
button.Enabled = command.CanExecute(commandParameter);
}
/// <summary>
/// Binds multiple buttons to commands at once
/// </summary>
public static void BindCommands(params (Button button, ICommand command, object parameter)[] bindings)
{
foreach (var (button, command, parameter) in bindings)
{
button.BindCommand(command, parameter);
}
}
}
}

View file

@ -9,14 +9,12 @@ using System.Diagnostics;
#if WINDOWS
using System.Drawing.Text;
#endif
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
#if WINDOWS
using System.Windows.Forms;
#endif
using System.Windows.Input;
using AutoMapper;
using MaddoShared;
@ -40,6 +38,8 @@ namespace ImageCatalog_2
public ICommand SelectModelsFolderCommand { get; }
public ICommand SelectCsvOutputCommand { get; }
public ICommand StartAiCommand { get; }
public ICommand StartFaceEncoderCommand { get; }
public ICommand StopFaceEncoderCommand { get; }
private readonly ITestService _service;
private readonly ILogger<DataModel> _logger;
@ -54,6 +54,15 @@ namespace ImageCatalog_2
private readonly VisualSettingsViewModel _visual;
private readonly PicSettings _picSettings;
private readonly IMapper _mapper;
private readonly AsyncCommand _startFaceEncoderCommand;
private readonly AsyncCommand _stopFaceEncoderCommand;
private readonly object _faceEncoderProcessLock = new();
private Process? _faceEncoderProcess;
private CancellationTokenSource? _faceEncoderWatcherTokenSource;
private Task? _faceEncoderWatcherTask;
private CancellationTokenSource? _faceEncoderLogWatcherTokenSource;
private Task? _faceEncoderLogWatcherTask;
private bool _hasStartedFaceEncoderInSession;
// ComboBox collections
public List<string> AvailableFonts { get; }
@ -93,6 +102,10 @@ namespace ImageCatalog_2
SelectModelsFolderCommand = new RelayCommand(SelectModelsFolder);
SelectCsvOutputCommand = new RelayCommand(SelectCsvOutput);
StartAiCommand = new AsyncCommand(StartAiAsync);
_startFaceEncoderCommand = new AsyncCommand(RunFaceEncoderAsync, CanRunFaceEncoder);
_stopFaceEncoderCommand = new AsyncCommand(() => StopFaceEncoderAsync("Arresto richiesto dall'utente."), CanStopFaceEncoder);
StartFaceEncoderCommand = _startFaceEncoderCommand;
StopFaceEncoderCommand = _stopFaceEncoderCommand;
SelectSourceFolderCommand = new RelayCommand(SelectSourceFolder);
SelectDestinationFolderCommand = new RelayCommand(SelectDestinationFolder);
@ -104,6 +117,7 @@ namespace ImageCatalog_2
// Load available fonts
AvailableFonts = LoadAvailableFonts();
RefreshFaceExecutableCapabilities();
}
private async Task StartAiAsync()
@ -151,8 +165,7 @@ namespace ImageCatalog_2
}
/// <summary>
/// Optional UI-thread invoker set by the active UI layer (WPF, Avalonia, etc.).
/// When set, <see cref="InvokeOnUiThreadAsync"/> uses this delegate instead of the WPF dispatcher.
/// Optional UI-thread invoker set by the active UI layer.
/// </summary>
public Action<Action>? UiInvoker { get; set; }
@ -163,7 +176,7 @@ namespace ImageCatalog_2
if (UiInvoker != null)
UiInvoker(action);
else
System.Windows.Application.Current?.Dispatcher.Invoke(action);
action();
});
}
@ -192,7 +205,18 @@ namespace ImageCatalog_2
public string FaceExecutablePath
{
get => _ai.FaceExecutablePath;
set => _ai.FaceExecutablePath = value;
set
{
var normalizedValue = value ?? string.Empty;
if (string.Equals(_ai.FaceExecutablePath, normalizedValue, StringComparison.Ordinal))
{
RefreshFaceExecutableCapabilities();
return;
}
_ai.FaceExecutablePath = normalizedValue;
RefreshFaceExecutableCapabilities();
}
}
public string FaceOutputFolderPath
@ -201,6 +225,64 @@ namespace ImageCatalog_2
set => _ai.FaceOutputFolderPath = value;
}
public bool FaceRecursive
{
get => _ai.FaceRecursive;
set => _ai.FaceRecursive = value;
}
public bool FaceIncludeThumbnails
{
get => _ai.FaceIncludeThumbnails;
set => _ai.FaceIncludeThumbnails = value;
}
public IReadOnlyList<int> FaceParallelismOptions { get; } = [1, 2, 3, 4, 5];
public int FaceParallelism
{
get => _ai.FaceParallelism;
set => _ai.FaceParallelism = value;
}
public int FaceMinSize
{
get => _ai.FaceMinSize;
set => _ai.FaceMinSize = value;
}
public bool FaceUpsample
{
get => _ai.FaceUpsample;
set => _ai.FaceUpsample = value;
}
public bool FaceGpuOptionEnabled => _ai.FaceGpuOptionEnabled;
public bool UseFaceGpu
{
get => _ai.UseFaceGpu;
set => SetUseFaceGpu(value);
}
public bool IsFaceEncoderRunning
{
get => _ai.IsFaceEncoderRunning;
private set => _ai.IsFaceEncoderRunning = value;
}
public string FaceStatusMessage
{
get => _ai.FaceStatusMessage;
private set => _ai.FaceStatusMessage = value;
}
public string FaceCommandOutput
{
get => _ai.FaceCommandOutput;
private set => _ai.FaceCommandOutput = value;
}
// Race upload settings
public string ApiLogin
{
@ -393,6 +475,7 @@ namespace ImageCatalog_2
}
NotifyPropertyChanged(e.PropertyName);
UpdateFaceEncoderCommandStates();
}
private void OnRaceUploadPropertyChanged(object? sender, PropertyChangedEventArgs e)
@ -1272,6 +1355,785 @@ namespace ImageCatalog_2
}
}
public async Task StopFaceEncoderAsync(string reason, bool waitForExit = true)
{
var trackedProcess = GetTrackedFaceEncoderProcess();
var ownsProcess = false;
var process = trackedProcess;
if (process is null)
{
process = FindConfiguredFaceEncoderProcess();
ownsProcess = process is not null;
}
if (process is null)
{
await InvokeOnUiThreadAsync(() =>
{
IsFaceEncoderRunning = false;
FaceStatusMessage = "Face encoder non in esecuzione.";
}).ConfigureAwait(false);
return;
}
try
{
await InvokeOnUiThreadAsync(() => FaceStatusMessage = reason).ConfigureAwait(false);
var gracefulStopRequested = TryRequestFaceEncoderStop(process);
if (waitForExit)
{
var exited = await WaitForProcessExitAsync(process, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
if (!exited)
{
try
{
process.Kill(entireProcessTree: true);
exited = await WaitForProcessExitAsync(process, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to terminate face encoder process {ProcessId}", process.Id);
}
}
await InvokeOnUiThreadAsync(() =>
{
IsFaceEncoderRunning = !exited && IsProcessAlive(process);
FaceStatusMessage = exited
? "Face encoder arrestato."
: gracefulStopRequested
? "Segnale di arresto inviato al face encoder."
: "Arresto forzato del face encoder richiesto.";
}).ConfigureAwait(false);
}
}
finally
{
if (ownsProcess)
{
process.Dispose();
}
}
}
private bool CanRunFaceEncoder()
{
return !IsFaceEncoderRunning;
}
private bool CanStopFaceEncoder()
{
return IsFaceEncoderRunning;
}
private void UpdateFaceEncoderCommandStates()
{
_startFaceEncoderCommand?.RaiseCanExecuteChanged();
_stopFaceEncoderCommand?.RaiseCanExecuteChanged();
}
private async Task RunFaceEncoderAsync()
{
if (IsFaceEncoderRunning)
{
FaceStatusMessage = "Face encoder gia in esecuzione.";
return;
}
var executableRootPath = NormalizeFilePathArgument(FaceExecutablePath);
var outputFolderPath = NormalizeDirectoryPathArgument(FaceOutputFolderPath);
var imagesFolder = NormalizeDirectoryPathArgument(DestinationPath);
var executablePath = ResolveConfiguredFaceEncoderExecutablePath(executableRootPath, UseFaceGpu);
if (string.IsNullOrWhiteSpace(executableRootPath))
{
FaceStatusMessage = "Percorso cartella face encoder non valido.";
return;
}
if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath))
{
FaceStatusMessage = UseFaceGpu
? "Impossibile trovare face_encoder_gpu.exe nella cartella selezionata."
: "Impossibile trovare face_encoder_cpu.exe nella cartella selezionata.";
return;
}
if (string.IsNullOrWhiteSpace(imagesFolder) || !Directory.Exists(imagesFolder))
{
FaceStatusMessage = "Cartella Destinazione non valida.";
return;
}
if (string.IsNullOrWhiteSpace(outputFolderPath))
{
FaceStatusMessage = "Inserisci la cartella di output per encodings e log.";
return;
}
try
{
Directory.CreateDirectory(outputFolderPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to create face output directory: {OutputFolderPath}", outputFolderPath);
FaceStatusMessage = "Impossibile creare la cartella di output.";
return;
}
var parallelism = NormalizeFaceParallelism(FaceParallelism);
var minSize = NormalizeFaceMinSize(FaceMinSize);
var outputFiles = BuildFaceEncoderOutputPaths(outputFolderPath, imagesFolder, DateTime.Now);
FaceExecutablePath = executableRootPath;
FaceOutputFolderPath = outputFolderPath;
FaceCommandOutput = string.Empty;
FaceStatusMessage = "Esecuzione face encoder in corso...";
var transcriptLines = new StringBuilder();
var outputLines = new StringBuilder();
var errorLines = new StringBuilder();
try
{
var processStartInfo = new ProcessStartInfo
{
FileName = executablePath,
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = false,
CreateNoWindow = false,
};
processStartInfo.ArgumentList.Add("--images");
processStartInfo.ArgumentList.Add(imagesFolder);
processStartInfo.ArgumentList.Add("--out");
processStartInfo.ArgumentList.Add(outputFiles.OutputFilePath);
processStartInfo.ArgumentList.Add("--log");
processStartInfo.ArgumentList.Add(outputFiles.LogFilePath);
if (FaceRecursive)
{
processStartInfo.ArgumentList.Add("--recursive");
}
if (FaceIncludeThumbnails)
{
processStartInfo.ArgumentList.Add("--include-tn");
}
processStartInfo.ArgumentList.Add(UseFaceGpu ? "--multiprocess" : "--multicore");
processStartInfo.ArgumentList.Add(parallelism.ToString());
processStartInfo.ArgumentList.Add("--min-size");
processStartInfo.ArgumentList.Add(minSize.ToString());
if (FaceUpsample)
{
processStartInfo.ArgumentList.Add("--upsample");
}
using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, args) => AppendFaceProcessOutput(outputLines, transcriptLines, args.Data, isError: false);
process.ErrorDataReceived += (_, args) => AppendFaceProcessOutput(errorLines, transcriptLines, args.Data, isError: true);
process.Exited += (_, _) =>
{
_ = InvokeOnUiThreadAsync(() =>
{
if (!ComputeIsFaceEncoderRunning())
{
IsFaceEncoderRunning = false;
}
});
};
if (!process.Start())
{
throw new InvalidOperationException("Avvio face encoder fallito.");
}
_hasStartedFaceEncoderInSession = true;
EnsureFaceEncoderWatcherStarted();
TrackFaceEncoderProcess(process);
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = true).ConfigureAwait(false);
if (UseFaceGpu)
{
StartFaceEncoderLogWatcher(outputFiles.LogFilePath, outputLines, transcriptLines);
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync().ConfigureAwait(false);
var summary = BuildFaceEncoderSummary(process.ExitCode, processStartInfo, outputFiles.OutputFilePath, outputFiles.LogFilePath, outputLines, errorLines);
await InvokeOnUiThreadAsync(() =>
{
FaceCommandOutput = string.IsNullOrWhiteSpace(FaceCommandOutput)
? summary
: $"{FaceCommandOutput.TrimEnd()}\n\n{summary}";
FaceStatusMessage = process.ExitCode == 0
? "Face encoder completato."
: $"Face encoder terminato con errore (code {process.ExitCode}).";
}).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
_logger.LogError(ex, "Face encoder execution failed.");
await InvokeOnUiThreadAsync(() =>
{
FaceCommandOutput = ex.ToString();
FaceStatusMessage = "Errore durante esecuzione face encoder.";
}).ConfigureAwait(false);
}
finally
{
await StopFaceEncoderLogWatcherAsync().ConfigureAwait(false);
ClearTrackedFaceEncoderProcess();
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = ComputeIsFaceEncoderRunning()).ConfigureAwait(false);
}
}
private async Task WatchFaceEncoderProcessAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
{
if (!_hasStartedFaceEncoderInSession)
{
continue;
}
var isRunning = ComputeIsFaceEncoderRunning();
if (isRunning != IsFaceEncoderRunning)
{
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = isRunning).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// App shutdown.
}
}
private void EnsureFaceEncoderWatcherStarted()
{
if (_faceEncoderWatcherTask is not null)
{
return;
}
_faceEncoderWatcherTokenSource = new CancellationTokenSource();
_faceEncoderWatcherTask = WatchFaceEncoderProcessAsync(_faceEncoderWatcherTokenSource.Token);
}
private void StartFaceEncoderLogWatcher(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines)
{
_faceEncoderLogWatcherTokenSource?.Cancel();
_faceEncoderLogWatcherTokenSource?.Dispose();
_faceEncoderLogWatcherTokenSource = new CancellationTokenSource();
_faceEncoderLogWatcherTask = WatchFaceEncoderLogFileAsync(logFilePath, outputLines, transcriptLines, _faceEncoderLogWatcherTokenSource.Token);
}
private async Task StopFaceEncoderLogWatcherAsync()
{
var tokenSource = _faceEncoderLogWatcherTokenSource;
var task = _faceEncoderLogWatcherTask;
_faceEncoderLogWatcherTokenSource = null;
_faceEncoderLogWatcherTask = null;
if (tokenSource is null)
{
return;
}
try
{
await tokenSource.CancelAsync().ConfigureAwait(false);
if (task is not null)
{
await task.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Expected when shutting down the watcher.
}
finally
{
tokenSource.Dispose();
}
}
private async Task WatchFaceEncoderLogFileAsync(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines, CancellationToken token)
{
long filePosition = 0;
while (!token.IsCancellationRequested)
{
try
{
if (File.Exists(logFilePath))
{
using var stream = new FileStream(logFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if (filePosition > stream.Length)
{
filePosition = 0;
}
stream.Seek(filePosition, SeekOrigin.Begin);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync(token).ConfigureAwait(false);
AppendFaceProcessOutput(outputLines, transcriptLines, line, isError: false);
}
filePosition = stream.Position;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (IOException)
{
// Retry on the next polling interval while the encoder is still writing.
}
catch (UnauthorizedAccessException)
{
// Retry on the next polling interval if the log file is transiently locked.
}
await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false);
}
}
private void RefreshFaceExecutableCapabilities()
{
var executableRoot = NormalizeFilePathArgument(_ai.FaceExecutablePath);
var hasCpu = !string.IsNullOrWhiteSpace(ResolveConfiguredFaceEncoderExecutablePath(executableRoot, useGpu: false));
var hasGpu = !string.IsNullOrWhiteSpace(ResolveConfiguredFaceEncoderExecutablePath(executableRoot, useGpu: true));
_ai.FaceGpuOptionEnabled = hasCpu && hasGpu;
if (hasGpu && !hasCpu)
{
_ai.UseFaceGpu = true;
}
else if (!hasGpu)
{
_ai.UseFaceGpu = false;
}
}
private void SetUseFaceGpu(bool value)
{
var currentValue = _ai.UseFaceGpu;
if (!FaceGpuOptionEnabled)
{
return;
}
if (currentValue == value)
{
return;
}
_ai.UseFaceGpu = value;
var previousRecommendedUpsample = GetRecommendedFaceUpsample(currentValue);
if (_ai.FaceUpsample == previousRecommendedUpsample)
{
_ai.FaceUpsample = GetRecommendedFaceUpsample(value);
}
}
private void TrackFaceEncoderProcess(Process process)
{
lock (_faceEncoderProcessLock)
{
_faceEncoderProcess = process;
}
}
private void ClearTrackedFaceEncoderProcess()
{
lock (_faceEncoderProcessLock)
{
if (_faceEncoderProcess is not null && _faceEncoderProcess.HasExited)
{
_faceEncoderProcess = null;
return;
}
_faceEncoderProcess = null;
}
}
private Process? GetTrackedFaceEncoderProcess()
{
lock (_faceEncoderProcessLock)
{
if (_faceEncoderProcess is null)
{
return null;
}
if (_faceEncoderProcess.HasExited)
{
_faceEncoderProcess = null;
return null;
}
return _faceEncoderProcess;
}
}
private Process? FindConfiguredFaceEncoderProcess()
{
var configuredExecutablePath = ResolveConfiguredFaceEncoderExecutablePath(FaceExecutablePath, UseFaceGpu);
if (string.IsNullOrWhiteSpace(configuredExecutablePath))
{
return null;
}
var processName = Path.GetFileNameWithoutExtension(configuredExecutablePath);
foreach (var process in Process.GetProcessesByName(processName))
{
if (!IsProcessAlive(process))
{
process.Dispose();
continue;
}
if (IsMatchingProcessPath(process, configuredExecutablePath))
{
return process;
}
process.Dispose();
}
return null;
}
private bool ComputeIsFaceEncoderRunning()
{
var trackedProcess = GetTrackedFaceEncoderProcess();
if (trackedProcess is not null)
{
return true;
}
if (!_hasStartedFaceEncoderInSession)
{
return false;
}
using var discoveredProcess = FindConfiguredFaceEncoderProcess();
return discoveredProcess is not null;
}
private static bool IsProcessAlive(Process process)
{
try
{
return !process.HasExited;
}
catch
{
return false;
}
}
private static bool IsMatchingProcessPath(Process process, string configuredExecutablePath)
{
try
{
var processPath = process.MainModule?.FileName;
return !string.IsNullOrWhiteSpace(processPath)
&& string.Equals(Path.GetFullPath(processPath), Path.GetFullPath(configuredExecutablePath), StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private bool TryRequestFaceEncoderStop(Process process)
{
if (!IsProcessAlive(process))
{
return true;
}
#if WINDOWS
if (Program.TrySendConsoleInterrupt(process.Id))
{
return true;
}
#endif
try
{
return process.CloseMainWindow();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to request graceful stop for face encoder process {ProcessId}", process.Id);
return false;
}
}
private static async Task<bool> WaitForProcessExitAsync(Process process, TimeSpan timeout)
{
if (!IsProcessAlive(process))
{
return true;
}
using var cancellationTokenSource = new CancellationTokenSource(timeout);
try
{
await process.WaitForExitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
return !IsProcessAlive(process);
}
}
private void AppendFaceProcessOutput(StringBuilder builder, StringBuilder transcriptBuilder, string? line, bool isError)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
lock (builder)
{
builder.AppendLine(line);
}
if (isError)
{
Console.Error.WriteLine(line);
}
else
{
Console.WriteLine(line);
}
string transcript;
lock (transcriptBuilder)
{
if (isError)
{
transcriptBuilder.Append("[stderr] ");
}
transcriptBuilder.AppendLine(line);
transcript = transcriptBuilder.ToString();
}
_ = InvokeOnUiThreadAsync(() => FaceCommandOutput = transcript);
}
internal static string? ResolveConfiguredFaceEncoderExecutablePath(string configuredPath, bool useGpu)
{
var variant = useGpu ? "gpu" : "cpu";
foreach (var candidate in EnumerateFaceEncoderExecutableCandidates(configuredPath, variant))
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
internal static (string OutputFilePath, string LogFilePath) BuildFaceEncoderOutputPaths(string outputFolderPath, string imagesFolderPath, DateTime timestamp)
{
var safeRaceName = BuildSafeFaceEncoderRaceName(imagesFolderPath);
var timestampToken = timestamp.ToString("yyyyMMdd_HHmmss");
return (
Path.Combine(outputFolderPath, $"face_encodings_{timestampToken}_{safeRaceName}.pkl"),
Path.Combine(outputFolderPath, $"encoder_log_{timestampToken}_{safeRaceName}.txt"));
}
internal static string BuildSafeFaceEncoderRaceName(string imagesFolderPath)
{
var raceName = new DirectoryInfo(imagesFolderPath).Name;
if (string.IsNullOrWhiteSpace(raceName))
{
return "race";
}
var invalidChars = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(raceName.Length);
var previousWasSeparator = false;
foreach (var currentChar in raceName)
{
if (char.IsWhiteSpace(currentChar) || invalidChars.Contains(currentChar))
{
if (!previousWasSeparator)
{
builder.Append('_');
previousWasSeparator = true;
}
continue;
}
builder.Append(currentChar);
previousWasSeparator = false;
}
return builder.ToString().Trim('_') switch
{
"" => "race",
var sanitized => sanitized
};
}
private static IEnumerable<string> EnumerateFaceEncoderExecutableCandidates(string configuredPath, string variant)
{
var normalizedPath = NormalizeFilePathArgument(configuredPath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
yield break;
}
var executableName = $"face_encoder_{variant}.exe";
if (File.Exists(normalizedPath))
{
var fileDirectory = Path.GetDirectoryName(normalizedPath);
if (!string.IsNullOrWhiteSpace(fileDirectory))
{
yield return Path.Combine(fileDirectory, executableName);
var parentDirectory = Directory.GetParent(fileDirectory)?.FullName;
if (!string.IsNullOrWhiteSpace(parentDirectory))
{
yield return Path.Combine(parentDirectory, $"face_encoder_{variant}", executableName);
}
}
yield return normalizedPath;
yield break;
}
yield return Path.Combine(normalizedPath, executableName);
yield return Path.Combine(normalizedPath, $"face_encoder_{variant}", executableName);
var leafName = Path.GetFileName(normalizedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (leafName.Equals("face_encoder_cpu", StringComparison.OrdinalIgnoreCase)
|| leafName.Equals("face_encoder_gpu", StringComparison.OrdinalIgnoreCase))
{
var parentDirectory = Directory.GetParent(normalizedPath)?.FullName;
if (!string.IsNullOrWhiteSpace(parentDirectory))
{
yield return Path.Combine(parentDirectory, $"face_encoder_{variant}", executableName);
}
}
}
private static int NormalizeFaceParallelism(int value)
{
return value is >= 1 and <= 5 ? value : 3;
}
private static int NormalizeFaceMinSize(int value)
{
return value > 0 ? value : 35;
}
private static bool GetRecommendedFaceUpsample(bool useGpu)
{
return !useGpu;
}
private static string BuildFaceEncoderSummary(
int exitCode,
ProcessStartInfo processStartInfo,
string outputFilePath,
string logFilePath,
StringBuilder outputLines,
StringBuilder errorLines)
{
var summary = new StringBuilder();
summary.AppendLine($"Exit code: {exitCode}");
summary.AppendLine($"Command: {processStartInfo.FileName} {string.Join(" ", processStartInfo.ArgumentList)}");
summary.AppendLine($"Output file: {outputFilePath}");
summary.AppendLine($"Log file: {logFilePath}");
lock (outputLines)
{
if (outputLines.Length > 0)
{
summary.AppendLine();
summary.AppendLine("STDOUT:");
summary.Append(outputLines);
}
}
lock (errorLines)
{
if (errorLines.Length > 0)
{
summary.AppendLine();
summary.AppendLine("STDERR:");
summary.Append(errorLines);
}
}
return summary.ToString();
}
private static string NormalizeDirectoryPathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Trim('"');
var root = Path.GetPathRoot(normalized);
if (!string.IsNullOrEmpty(root) && normalized.Length > root.Length)
{
normalized = normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return normalized;
}
private static string NormalizeFilePathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Trim('"');
}
// Note: These commands will trigger events that the View will handle to show dialogs
// since dialogs require UI context
public event EventHandler? SelectSourceFolderRequested;
@ -1283,11 +2145,7 @@ namespace ImageCatalog_2
public event EventHandler<string?>? LoadSettingsRequested;
public event EventHandler? SelectColorRequested;
// Request that the View shows a message to the user (message, caption, icon)
#if WINDOWS
public event EventHandler<Tuple<string, string, MessageBoxIcon>>? ShowMessageRequested;
#else
public event EventHandler<Tuple<string, string, int>>? ShowMessageRequested;
#endif
public event EventHandler? SelectTransparentColorRequested;
private void SelectSourceFolder(object parameter)

View file

@ -12,8 +12,6 @@
<PropertyGroup Condition="$([MSBuild]::IsOsPlatform('Windows'))">
<TargetFramework>net10.0-windows</TargetFramework>
<OutputType>WinExe</OutputType>
<UseWindowsForms>true</UseWindowsForms>
<UseWPF>true</UseWPF>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ApplicationIcon>Logo.ico</ApplicationIcon>
</PropertyGroup>
@ -31,9 +29,7 @@
<PublishReadyToRun Condition="'$(PublishReadyToRun)' == ''">false</PublishReadyToRun>
</PropertyGroup>
<PropertyGroup>
<!-- Keep MinVer package enabled but do NOT let it overwrite AssemblyVersion/FileVersion used at build-time.
This prevents MinVer from injecting a computed version into generated BAML/pack URIs which can cause
WPF to try loading a mismatched assembly identity at runtime. Do a full clean rebuild after this change. -->
<!-- Keep MinVer package enabled but do NOT let it overwrite AssemblyVersion/FileVersion used at build-time. -->
<UpdateVersionProperties>true</UpdateVersionProperties>
<!-- Skip MinVer execution during local builds to avoid environment/runtime-specific failures. -->
<MinVerSkip>true</MinVerSkip>
@ -65,11 +61,9 @@
<PackageReference Include="AIFotoONLUS.Core" Version="0.1.1" />
<PackageReference Include="AutoMapper" Version="16.1.0" />
<PackageReference Include="IconPacks.Avalonia" Version="1.3.1" />
<PackageReference Include="MahApps.Metro" Version="2.4.11" Condition="$([MSBuild]::IsOsPlatform('Windows'))" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.5" />
<PackageReference Include="MahApps.Metro.IconPacks" Version="6.2.1" Condition="$([MSBuild]::IsOsPlatform('Windows'))" />
<PackageReference Include="MinVer" Version="7.0.0" PrivateAssets="all" />
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
@ -81,8 +75,7 @@
</ItemGroup>
<!-- MinVer provides a computed 'Version' property. Do not automatically override AssemblyVersion/FileVersion
with MinVer's computed Version to avoid mismatches embedded into generated XAML/BAML. The explicit
AssemblyVersion/FileVersion at the top of this file will be used for runtime identity. -->
with MinVer's computed Version. The explicit AssemblyVersion/FileVersion at the top of this file will be used for runtime identity. -->
<ItemGroup>
<Compile Update="Properties\Settings.Designer.cs">
<DesignTimeSharedInput>True</DesignTimeSharedInput>
@ -90,11 +83,6 @@
<DependentUpon>Settings.settings</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="MainForm.resx">
<DependentUpon>MainForm.cs</DependentUpon>
</EmbeddedResource>
</ItemGroup>
<PropertyGroup />
<!-- No Visual Studio fallback required for MinVer; MinVer integrates with MSBuild during build. -->

File diff suppressed because it is too large Load diff

View file

@ -1,842 +0,0 @@
#if WINDOWS
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Text;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using ImageCatalog_2;
using ImageCatalog_2.Commands;
using ImageCatalog_2.Services;
using MaddoShared;
using Microsoft.Extensions.Logging;
namespace ImageCatalog;
public partial class MainForm
{
private readonly DataModel Model;
private readonly ILogger<MainForm> _logger;
private readonly ParametriSetup _parametriSetup;
private readonly PicSettings _picSettings;
// Prevent re-entrant updates between UI events and model PropertyChanged handling
private bool _suppressRadioUpdates = false;
private bool _transparentDialogOpen = false;
public MainForm(DataModel model, ImageCreationService imageCreationStuff, PicSettings picSettings,
ParametriSetup parametriSetup, ILogger<MainForm> logger)
{
Model = model;
_parametriSetup = parametriSetup;
_picSettings = picSettings;
_logger = logger;
_logger.LogDebug("Start");
InitializeComponent();
// Set this form as the control for thread marshalling in the DataModel
Model.SetControl(this);
// Ensure the designer data bindings have a concrete DataSource immediately so
// that UI controls (radio buttons) reflect the ViewModel state and propagate
// user changes back to the ViewModel.
bindingSource1.DataSource = Model;
BindControls();
// The designer originally bound the radio buttons to boolean helpers on the ViewModel.
// Those two separate bindings can fight with each other. Clear designer bindings and
// wire explicit Click handlers that update the single authoritative property
// `Model.ImageLibrary`. Also keep a PropertyChanged listener to reflect external
// changes back into the radio buttons.
rdbLibrary1.DataBindings.Clear();
rdbLibrary2.DataBindings.Clear();
// Initialize radio state from model
rdbLibrary1.Checked = Model.UseSystemGraphics;
rdbLibrary2.Checked = Model.UseImageSharp;
// Use Click handlers (not CheckedChanged) to avoid competing binding updates
rdbLibrary1.Click += (_, _) =>
{
if (_suppressRadioUpdates) return;
if (Model.ImageLibrary != "System.Graphics")
Model.ImageLibrary = "System.Graphics";
};
rdbLibrary2.Click += (_, _) =>
{
if (_suppressRadioUpdates) return;
if (Model.ImageLibrary != "ImageSharp")
Model.ImageLibrary = "ImageSharp";
};
// Watch for model changes so we can reflect external updates
Model.PropertyChanged += Model_PropertyChanged;
// Thumbnail options moved to ComboBox to avoid conflicting bindings with multiple radio buttons
// Initialize ComboBox with Italian descriptions
comboThumbnailOption.Items.Clear();
comboThumbnailOption.Items.AddRange(new object[] {
"Nessuna",
"Aggiungi scritta",
"Nome file",
"Aggiungi orario",
"Nome+Orario",
"Tempo gara"
});
// Bind to model via helper index property ThumbnailOptionIndex
comboThumbnailOption.DataBindings.Add(new Binding("SelectedIndex", bindingSource1, "ThumbnailOptionIndex", true, DataSourceUpdateMode.OnPropertyChanged));
// Save user preferences on form close instead of immediately when dialogs are used
this.FormClosing += MainForm_FormClosing;
// Wire up 'Open folder in Explorer' buttons
btnOpenSourceFolder.Click += BtnOpenSourceFolder_Click;
btnOpenDestFolder.Click += BtnOpenDestFolder_Click;
// Show currently selected color in small PictureBox3
PictureBox3.BackColor = ColorTranslator.FromHtml(Model.TransparentColor);
// Version label is data-bound to DataModel.AppVersion; DataModel is populated with the version via DI
}
private void RdbLibrary_CheckedChanged(object? sender, EventArgs e)
{
// Keep behavior simple: when a radio button becomes checked, update the ViewModel
// so that the designer binding and PicSettings stay in sync.
if (sender is RadioButton rb && rb.Checked)
{
_logger?.LogDebug("Radio library changed: {RadioName}", rb.Name);
if (rb == rdbLibrary2)
{
Model.ImageLibrary = "ImageSharp";
}
else if (rb == rdbLibrary1)
{
Model.ImageLibrary = "System.Graphics";
}
}
}
private void Model_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is null) return;
if (e.PropertyName == nameof(Model.ImageLibrary) || e.PropertyName == nameof(Model.UseImageSharp) || e.PropertyName == nameof(Model.UseSystemGraphics))
{
_logger?.LogDebug("Model property changed: {Property} => ImageLibrary={ImageLibrary}, PicSettings.Provider={Provider}", e.PropertyName, Model.ImageLibrary, _picSettings.ImageCreatorProvider);
// Reflect authoritative model value into the radio buttons in a thread-safe, re-entrancy-safe way
try
{
_suppressRadioUpdates = true;
rdbLibrary1.Checked = Model.UseSystemGraphics;
rdbLibrary2.Checked = Model.UseImageSharp;
}
finally
{
_suppressRadioUpdates = false;
}
}
// Thumbnail mode changes - reflect back to combo box index
if (e.PropertyName == nameof(Model.ThumbnailMode) ||
e.PropertyName == nameof(Model.ThumbnailOption) ||
e.PropertyName == nameof(Model.ThumbnailOptionIndex))
{
try
{
_suppressRadioUpdates = true;
comboThumbnailOption.SelectedIndex = Model.ThumbnailOptionIndex;
}
finally
{
_suppressRadioUpdates = false;
}
}
}
protected void BindControls()
{
// Bind buttons to ViewModel commands using command binding
_btnCreaCatalogoAsync.BindCommand(Model.ProcessImagesCommand);
// Note: `button1` control does not exist in the designer. Use the primary create button only.
_Button2.BindCommand(Model.SelectSourceFolderCommand);
_Button3.BindCommand(Model.SelectDestinationFolderCommand);
_Button4.BindCommand(Model.SelectLogoFileCommand);
_Button5.BindCommand(Model.SaveSettingsCommand);
_Button6.BindCommand(Model.LoadSettingsCommand);
_Button8.BindCommand(Model.SelectColorCommand);
// Bind the transparency chooser button/command
btnSetTransparency.BindCommand(Model.SelectTransparentColorCommand);
// Subscribe to ViewModel events for UI dialogs (these need UI context)
Model.SelectSourceFolderRequested += OnSelectSourceFolderRequested;
Model.SelectDestinationFolderRequested += OnSelectDestinationFolderRequested;
Model.SelectLogoFileRequested += OnSelectLogoFileRequested;
Model.SaveSettingsRequested += OnSaveSettingsRequested;
Model.LoadSettingsRequested += OnLoadSettingsRequested;
Model.SelectColorRequested += OnSelectColorRequested;
Model.SelectTransparentColorRequested += OnSelectTransparentColorRequested;
// Show message requests (from ViewModel validation)
Model.ShowMessageRequested += OnShowMessageRequested;
}
private void OnSelectTransparentColorRequested(object? sender, EventArgs e)
{
// Ensure UI thread
if (InvokeRequired)
{
Invoke(new Action<object, EventArgs>(OnSelectTransparentColorRequested), sender, e as EventArgs ?? EventArgs.Empty);
return;
}
// Prevent re-entrancy: if the dialog is already open, ignore subsequent requests
if (_transparentDialogOpen) return;
_transparentDialogOpen = true;
var dlg = new ColorDialog { AllowFullOpen = true };
try
{
dlg.Color = ColorTranslator.FromHtml(Model.TransparentColor);
}
catch { }
try
{
if (dlg.ShowDialog() == DialogResult.OK)
{
Model.TransparentColor = ColorTranslator.ToHtml(dlg.Color);
PictureBox3.BackColor = dlg.Color;
// Update preview if logo exists
if (!string.IsNullOrWhiteSpace(Model.LogoFile) && File.Exists(Model.LogoFile))
{
UpdateLogoPictureBox(Model.LogoFile);
}
}
}
finally
{
_transparentDialogOpen = false;
}
}
private void BtnSetTransparency_Click(object? sender, EventArgs e)
{
Model.SelectTransparentColorCommand.Execute(null);
}
private void OnShowMessageRequested(object? sender, Tuple<string, string, MessageBoxIcon> args)
{
if (args is null) return;
// Ensure call on UI thread
if (InvokeRequired)
{
Invoke(new Action(() => OnShowMessageRequested(sender, args)));
return;
}
MessageBox.Show(this, args.Item1, args.Item2, MessageBoxButtons.OK, args.Item3);
}
private void SetDefaults()
{
// Bind ComboBoxes to Model using proper data binding
ComboBox1.DataSource = new List<string>(Model.VerticalPositions);
ComboBox1.DataBindings.Add(new Binding("SelectedItem", bindingSource1, nameof(Model.VerticalPosition),
false, DataSourceUpdateMode.OnPropertyChanged));
ComboBox2.DataSource = new List<string>(Model.HorizontalAlignments);
ComboBox2.DataBindings.Add(new Binding("SelectedItem", bindingSource1, nameof(Model.HorizontalAlignment),
false, DataSourceUpdateMode.OnPropertyChanged));
ComboBox3.DataSource = new List<string>(Model.AvailableFonts);
ComboBox3.DataBindings.Add(new Binding("SelectedItem", bindingSource1, nameof(Model.FontName),
false, DataSourceUpdateMode.OnPropertyChanged));
ComboBox4.DataSource = new List<string>(Model.HorizontalAlignments);
ComboBox4.DataBindings.Add(new Binding("SelectedItem", bindingSource1, nameof(Model.LogoHorizontalPosition),
false, DataSourceUpdateMode.OnPropertyChanged));
ComboBox5.DataSource = new List<string> { "Alto", "Centro", "Basso" };
ComboBox5.DataBindings.Add(new Binding("SelectedItem", bindingSource1, nameof(Model.LogoVerticalPosition),
false, DataSourceUpdateMode.OnPropertyChanged));
// Bind progress bar and status labels
ProgressBar1.DataBindings.Add(new Binding("Maximum", bindingSource1, nameof(Model.ProgressBarMaximum),
false, DataSourceUpdateMode.OnPropertyChanged));
ProgressBar1.DataBindings.Add(new Binding("Value", bindingSource1, nameof(Model.ProgressBarValue),
false, DataSourceUpdateMode.OnPropertyChanged));
Label18.DataBindings.Add(new Binding("Text", bindingSource1, nameof(Model.ProcessedImagesCount),
false, DataSourceUpdateMode.OnPropertyChanged));
lblFotoTotaliNum.DataBindings.Add(new Binding("Text", bindingSource1, nameof(Model.TotalImagesCount),
false, DataSourceUpdateMode.OnPropertyChanged));
Label10.DataBindings.Add(new Binding("Text", bindingSource1, nameof(Model.ProcessingStatus),
false, DataSourceUpdateMode.OnPropertyChanged));
// Bind transparency model properties to UI
chkUseTransparentColor.DataBindings.Add(new Binding("Checked", bindingSource1, nameof(Model.UseTransparentColor), false, DataSourceUpdateMode.OnPropertyChanged));
// Show currently selected color in PictureBox3
PictureBox3.Visible = false;
if (!string.IsNullOrWhiteSpace(Model.TransparentColor))
{
try
{
PictureBox3.BackColor = ColorTranslator.FromHtml(Model.TransparentColor);
PictureBox3.Visible = true;
}
catch
{
PictureBox3.Visible = false;
}
}
// When logo file changes, update preview
Model.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(Model.LogoFile) || e.PropertyName == nameof(Model.UseTransparentColor) || e.PropertyName == nameof(Model.TransparentColor))
{
if (!string.IsNullOrWhiteSpace(Model.LogoFile) && System.IO.File.Exists(Model.LogoFile))
{
UpdateLogoPictureBox(Model.LogoFile);
}
}
};
// Bind transparent color hex and show color in PictureBox3
// Bind UseTransparentColor checkbox (designer control named CheckBox5 used for AddLogo earlier, add new binding control exists in designer)
// Use PictureBox3 to display color value
var colorBinding = new Binding("BackColor", bindingSource1, nameof(Model.TransparentColor), true, DataSourceUpdateMode.OnPropertyChanged);
colorBinding.Format += (s, e) =>
{
try
{
e.Value = ColorTranslator.FromHtml(e.Value?.ToString() ?? "#FFFFFF");
}
catch
{
e.Value = Color.White;
}
};
PictureBox3.DataBindings.Add(colorBinding);
// Bind checkbox for using color key transparency if such control exists (CheckBox5 was repurposed as AddLogo); create binding if available
try
{
// The designer has CheckBox5 for 'AddLogo'. We'll add a separate binding to a new control named CheckBoxUseTransparentColor if present.
var chk = this.Controls.Find("chkUseTransparentColor", true).FirstOrDefault() as CheckBox;
if (chk != null)
{
chk.DataBindings.Add(new Binding("Checked", bindingSource1, nameof(Model.UseTransparentColor), false, DataSourceUpdateMode.OnPropertyChanged));
}
}
catch { }
}
private void Form1_Load(object sender, EventArgs e)
{
bindingSource1.DataSource = Model;
Application.EnableVisualStyles();
SetDefaults();
_logger.LogInformation("Programma Avviato");
// If settings were loaded before the form was shown, ensure the logo preview is updated
try
{
if (!string.IsNullOrWhiteSpace(Model.LogoFile) && File.Exists(Model.LogoFile))
{
UpdateLogoPictureBox(Model.LogoFile);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load logo during form load");
}
}
private string CalcTime(DateTime timeStart, DateTime timeStop, int numFoto)
{
long timediffH, timediffS;
long timediffM;
TimeSpan timeDiff = timeStop - timeStart;
timediffM = (int)timeDiff.TotalMinutes;
timediffS = (int)timeDiff.TotalSeconds;
timediffH = (int)timeDiff.TotalHours;
// timediffM = DateAndTime.DateDiff(DateInterval.Minute, timeStart, timeStop);
// timediffS = DateAndTime.DateDiff(DateInterval.Second, timeStart, timeStop);
// timediffH = DateAndTime.DateDiff(DateInterval.Hour, timeStart, timeStop);
double fotoSec = numFoto / (double)timediffS;
double fotoMin = numFoto / (double)timediffM;
double fotoOra = numFoto / (double)timediffH;
string s = "S: " + timediffS.ToString() + "; F/s: " +
fotoSec.ToString(
"0.000"); // + " F/m: " + fotoMin.ToString("0.00") + " F/h: " + fotoOra.ToString("0.00")
return s;
}
private string SelectFolder(string startingFolder)
{
var dialog = new FolderBrowserDialog();
dialog.InitialDirectory = startingFolder;
if (dialog.ShowDialog() != DialogResult.OK) return string.Empty;
return FixPath(dialog.SelectedPath);
}
private string FixPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
// Trim leading/trailing quotes
path = path.Trim().Trim('"');
// Normalize directory separators
path = path.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
// Remove trailing separators then add one back
path = path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return path;
}
private void OnSelectSourceFolderRequested(object sender, EventArgs e)
{
// Prefer model value; if empty fall back to last-used value stored in user prefs
var starting = !string.IsNullOrWhiteSpace(Model.SourcePath)
? Model.SourcePath
: _parametriSetup.LeggiParametroString("LastSourceFolder");
var dialogResult = SelectFolder(starting);
if (!string.IsNullOrWhiteSpace(dialogResult))
{
Model.SourcePath = dialogResult;
_parametriSetup.AggiornaParametro("LastSourceFolder", dialogResult);
_parametriSetup.SalvaParametriSetup();
}
}
private void BtnOpenSourceFolder_Click(object? sender, EventArgs e)
{
// Prefer the model value but fall back to the textbox if needed
var path = string.IsNullOrWhiteSpace(Model.SourcePath) ? txtSorgente.Text : Model.SourcePath;
OpenFolder(path);
}
private void BtnOpenDestFolder_Click(object? sender, EventArgs e)
{
var path = string.IsNullOrWhiteSpace(Model.DestinationPath) ? txtDestinazione.Text : Model.DestinationPath;
OpenFolder(path);
}
private void OpenFolder(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
MessageBox.Show(this, "Folder path is empty.", "Open Folder", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
path = path.Trim().Trim('"');
try
{
if (File.Exists(path))
{
// If a file was provided, open its folder and select it
Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
if (Directory.Exists(path))
{
Process.Start(new ProcessStartInfo
{
FileName = path,
UseShellExecute = true
});
return;
}
MessageBox.Show(this, $"Folder does not exist: {path}", "Open Folder", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to open folder {Path}", path);
MessageBox.Show(this, $"Failed to open folder: {ex.Message}", "Open Folder", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void OnSelectDestinationFolderRequested(object sender, EventArgs e)
{
var starting = !string.IsNullOrWhiteSpace(Model.DestinationPath)
? Model.DestinationPath
: _parametriSetup.LeggiParametroString("LastDestinationFolder");
var dialogResult = SelectFolder(starting);
if (!string.IsNullOrWhiteSpace(dialogResult))
{
Model.DestinationPath = dialogResult;
_parametriSetup.AggiornaParametro("LastDestinationFolder", dialogResult);
_parametriSetup.SalvaParametriSetup();
}
}
private void OnSelectLogoFileRequested(object sender, EventArgs e)
{
var dialog = new OpenFileDialog();
dialog.Filter = "Image Files|*.jpg;*.jpeg;*.png;*.bmp;*.gif";
if (!string.IsNullOrWhiteSpace(Model.LogoFile))
{
dialog.FileName = Model.LogoFile;
}
else
{
var lastLogoFolder = _parametriSetup.LeggiParametroString("LastLogoFolder");
if (!string.IsNullOrWhiteSpace(lastLogoFolder) && Directory.Exists(lastLogoFolder))
{
dialog.InitialDirectory = lastLogoFolder;
}
}
if (dialog.ShowDialog() == DialogResult.OK)
{
Model.LogoFile = dialog.FileName;
UpdateLogoPictureBox(Model.LogoFile);
try
{
var folder = Path.GetDirectoryName(dialog.FileName) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(folder))
{
_parametriSetup.AggiornaParametro("LastLogoFolder", folder);
_parametriSetup.SalvaParametriSetup();
}
}
catch
{
// ignore preferences save failures
}
}
}
private async void OnSaveSettingsRequested(object sender, string e)
{
var saveDialog = new SaveFileDialog
{
Filter = "Setup (*.xml)|*.xml|All valid files (*.*)|*.*",
FilterIndex = 0,
RestoreDirectory = true
};
var lastSettings = _parametriSetup.LeggiParametroString("LastSettingsFolder");
if (!string.IsNullOrWhiteSpace(lastSettings) && Directory.Exists(lastSettings))
saveDialog.InitialDirectory = lastSettings;
if (saveDialog.ShowDialog() != DialogResult.OK) return;
await Model.SaveSettingsToFileAsync(saveDialog.FileName);
Text = "Image Catalog - " + Path.GetFileName(saveDialog.FileName);
try
{
var folder = Path.GetDirectoryName(saveDialog.FileName) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(folder))
{
_parametriSetup.AggiornaParametro("LastSettingsFolder", folder);
_parametriSetup.SalvaParametriSetup();
}
}
catch
{
// ignore
}
}
private async void OnLoadSettingsRequested(object sender, string e)
{
var openDialog = new OpenFileDialog
{
Filter = "Setup (*.xml)|*.xml|All valid files (*.*)|*.*",
FilterIndex = 0,
RestoreDirectory = true
};
var lastSettings = _parametriSetup.LeggiParametroString("LastSettingsFolder");
if (!string.IsNullOrWhiteSpace(lastSettings) && Directory.Exists(lastSettings))
openDialog.InitialDirectory = lastSettings;
if (openDialog.ShowDialog() != DialogResult.OK) return;
try
{
await Model.LoadSettingsFromFileAsync(openDialog.FileName);
// Explicitly ensure UI is enabled after loading
Model.UiEnabled = true;
// If a logo path was stored in the settings, try to resolve and display it.
// The stored path may be absolute or relative to the settings file location.
try
{
var storedLogo = Model.LogoFile;
// Trim whitespace and surrounding quotes which may be present in saved settings
if (!string.IsNullOrWhiteSpace(storedLogo))
{
storedLogo = storedLogo.Trim();
storedLogo = storedLogo.Trim('"');
}
if (!string.IsNullOrWhiteSpace(storedLogo))
{
string resolved = storedLogo;
// If not rooted, try to resolve relative to the settings file folder
var settingsFolder = Path.GetDirectoryName(openDialog.FileName) ?? string.Empty;
if (!Path.IsPathRooted(resolved) && !string.IsNullOrWhiteSpace(settingsFolder))
{
var candidate = Path.Combine(settingsFolder, resolved);
if (File.Exists(candidate)) resolved = candidate;
}
// If rooted but file doesn't exist, try filename near settings file
if (!File.Exists(resolved) && !string.IsNullOrWhiteSpace(settingsFolder))
{
var candidate2 = Path.Combine(settingsFolder, Path.GetFileName(resolved));
if (File.Exists(candidate2)) resolved = candidate2;
}
if (File.Exists(resolved))
{
// Update the model so data-bound controls reflect the resolved absolute path
Model.LogoFile = resolved;
UpdateLogoPictureBox(resolved);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error resolving logo path after loading settings");
}
Text = "Image Catalog - " + Path.GetFileName(openDialog.FileName);
try
{
var folder = Path.GetDirectoryName(openDialog.FileName) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(folder))
{
_parametriSetup.AggiornaParametro("LastSettingsFolder", folder);
_parametriSetup.SalvaParametriSetup();
}
}
catch
{
// ignore preferences save failures
}
_logger.LogInformation($"Settings loaded successfully from {openDialog.FileName}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading settings");
MessageBox.Show($"Error loading settings: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void MainForm_FormClosing(object? sender, FormClosingEventArgs e)
{
try
{
// Persist last-used dialogs paths (user preferences)
// These keys are managed independently from settings files
// and must be saved when the form closes.
_parametriSetup.AggiornaParametro("LastSourceFolder", Model.SourcePath ?? string.Empty);
_parametriSetup.AggiornaParametro("LastDestinationFolder", Model.DestinationPath ?? string.Empty);
_parametriSetup.AggiornaParametro("LastLogoFolder", Path.GetDirectoryName(Model.LogoFile ?? string.Empty) ?? string.Empty);
_parametriSetup.SalvaParametriSetup();
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to save user preferences on exit");
}
}
private void OnSelectColorRequested(object sender, EventArgs e)
{
var colorDialog = new ColorDialog
{
AllowFullOpen = true
};
if (!string.IsNullOrWhiteSpace(Model.TextColorRGB))
{
try
{
colorDialog.Color = ColorTranslator.FromHtml(Model.TextColorRGB);
}
catch
{
// Invalid color, use default
}
}
if (colorDialog.ShowDialog() == DialogResult.OK)
{
Model.TextColorRGB = ColorTranslator.ToHtml(colorDialog.Color);
TextBox34.BackColor = colorDialog.Color;
}
}
private void UpdateLogoPictureBox(string logoPath)
{
try
{
// Load image via System.Drawing for preview so we can use MakeTransparent when requested
using var img = System.Drawing.Image.FromFile(logoPath);
System.Drawing.Bitmap previewBmp;
// If using color-key transparency and a color is selected, apply MakeTransparent for preview
if (Model.UseTransparentColor && !string.IsNullOrWhiteSpace(Model.TransparentColor))
{
try
{
var key = ColorTranslator.FromHtml(Model.TransparentColor);
previewBmp = new System.Drawing.Bitmap(img.Width, img.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = System.Drawing.Graphics.FromImage(previewBmp))
{
g.Clear(System.Drawing.Color.Transparent);
g.DrawImage(img, 0, 0, img.Width, img.Height);
}
// Apply exact color-key transparency
previewBmp.MakeTransparent(key);
PictureBox3.BackColor = key;
PictureBox3.Visible = true;
}
catch
{
previewBmp = new System.Drawing.Bitmap(img);
}
}
else
{
previewBmp = new System.Drawing.Bitmap(img);
}
// Resize preview to fit into PictureBox1 while preserving aspect ratio
// Resize preview to fit into PictureBox1 while preserving aspect ratio
var boxW = PictureBox1.ClientSize.Width > 0 ? PictureBox1.ClientSize.Width : 449;
var boxH = PictureBox1.ClientSize.Height > 0 ? PictureBox1.ClientSize.Height : 369;
var ratio = Math.Min((double)boxW / previewBmp.Width, (double)boxH / previewBmp.Height);
var destW = Math.Max(1, (int)(previewBmp.Width * ratio));
var destH = Math.Max(1, (int)(previewBmp.Height * ratio));
var scaled = new System.Drawing.Bitmap(destW, destH, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = System.Drawing.Graphics.FromImage(scaled))
{
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.Clear(System.Drawing.Color.Transparent);
// Center the image in the PictureBox
var offsetX = Math.Max(0, (boxW - destW) / 2);
var offsetY = Math.Max(0, (boxH - destH) / 2);
using var canvas = new System.Drawing.Bitmap(boxW, boxH, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var cg = System.Drawing.Graphics.FromImage(canvas))
{
cg.Clear(System.Drawing.Color.Transparent);
cg.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
cg.DrawImage(previewBmp, offsetX, offsetY, destW, destH);
}
g.DrawImage(canvas, 0, 0);
}
// Set PictureBox1 image (dispose previous)
var old = PictureBox1.Image;
PictureBox1.SizeMode = PictureBoxSizeMode.CenterImage;
PictureBox1.Image = scaled;
old?.Dispose();
previewBmp.Dispose();
}
catch
{
// Image loading failed, ignore
}
}
private void UpdateLogoPreviewWithColorKey(string logoPath, Color keyColor)
{
try
{
using var img = (Bitmap)Image.FromFile(logoPath);
// Create ARGB copy
var bmp = new Bitmap(img.Width, img.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(bmp))
{
g.DrawImage(img, 0, 0, img.Width, img.Height);
}
bmp.MakeTransparent(keyColor);
// Resize to PictureBox1 size similar to previous logic
Bitmap finalBmp;
if (bmp.Height >= bmp.Width)
{
finalBmp = new Bitmap(bmp, new Size((int)(160 * bmp.Width / (double)bmp.Height), 160));
}
else
{
finalBmp = new Bitmap(bmp, new Size(160, (int)(160 * bmp.Height / (double)bmp.Width)));
}
PictureBox1.Image = finalBmp;
}
catch
{
// ignore preview failures
}
}
private void setLabel18Text(string text)
{
if (Label18.InvokeRequired)
{
Label18.Invoke(new Action<string>(setLabel18Text), text);
}
else
{
Label18.Text = text;
}
}
}
public class PicInfo
{
public DirectoryInfo DirSource, DirDest, DirDestStart;
public string NomeImmagine;
public PicInfo(DirectoryInfo Dir_Source, DirectoryInfo Dir_Dest, DirectoryInfo Dir_DestStart,
string Nome_Immagine)
{
DirSource = Dir_Source;
DirDest = Dir_Dest;
DirDestStart = Dir_DestStart;
NomeImmagine = Nome_Immagine;
}
}
#endif

View file

@ -1,135 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="bindingSource1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>586, 17</value>
</metadata>
<metadata name="dataModelBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>120, 17</value>
</metadata>
<metadata name="timer1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="dataModelBindingSource1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>349, 17</value>
</metadata>
<metadata name="colorDialog1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>802, 17</value>
</metadata>
</root>

View file

@ -1,510 +0,0 @@
<controls:MetroWindow x:Class="ImageCatalog_2.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:controls="http://metro.mahapps.com/winfx/xaml/controls"
mc:Ignorable="d"
Title="Image Catalog - WPF" Height="540" Width="800"
Background="{DynamicResource WindowBackgroundBrush}" Foreground="{DynamicResource ControlForegroundBrush}"
GlowBrush="{DynamicResource AccentBrush}">
<controls:MetroWindow.Resources>
<ResourceDictionary>
<!-- Default (Light) theme resources placed at top-level so DynamicResource lookups resolve -->
<!-- style moved later to avoid early resource lookup -->
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="White" />
<SolidColorBrush x:Key="ControlBackgroundBrush" Color="White" />
<SolidColorBrush x:Key="ControlForegroundBrush" Color="Black" />
<SolidColorBrush x:Key="BorderBrush" Color="#DDD" />
<SolidColorBrush x:Key="AccentBrush" Color="#0078D7" />
<SolidColorBrush x:Key="DataGridBackgroundBrush" Color="White" />
<SolidColorBrush x:Key="DataGridForegroundBrush" Color="Black" />
<!-- Also keep named theme dictionaries for future switching if needed -->
<ResourceDictionary x:Key="LightTheme">
<SolidColorBrush x:Key="WindowBackgroundBrush.Light" Color="White" />
<SolidColorBrush x:Key="ControlBackgroundBrush.Light" Color="White" />
<SolidColorBrush x:Key="ControlForegroundBrush.Light" Color="Black" />
<SolidColorBrush x:Key="BorderBrush.Light" Color="#DDD" />
<SolidColorBrush x:Key="AccentBrush.Light" Color="#0078D7" />
<SolidColorBrush x:Key="DataGridBackgroundBrush.Light" Color="White" />
<SolidColorBrush x:Key="DataGridForegroundBrush.Light" Color="Black" />
</ResourceDictionary>
<ResourceDictionary x:Key="DarkTheme">
<SolidColorBrush x:Key="WindowBackgroundBrush.Dark" Color="#1E1E1E" />
<SolidColorBrush x:Key="ControlBackgroundBrush.Dark" Color="#252526" />
<SolidColorBrush x:Key="ControlForegroundBrush.Dark" Color="#E6E6E6" />
<SolidColorBrush x:Key="BorderBrush.Dark" Color="#3A3A3A" />
<SolidColorBrush x:Key="AccentBrush.Dark" Color="#0A84FF" />
<SolidColorBrush x:Key="DataGridBackgroundBrush.Dark" Color="#252526" />
<SolidColorBrush x:Key="DataGridForegroundBrush.Dark" Color="#E6E6E6" />
</ResourceDictionary>
<!-- Improve tab header visuals so selected tab and boundaries are clear -->
<Style TargetType="controls:MetroTabItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource ControlForegroundBrush}" />
<Setter Property="Margin" Value="0,0,4,0" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="MinWidth" Value="56" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="controls:MetroTabItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="4"
BorderThickness="0"
Padding="{TemplateBinding Padding}">
<ContentPresenter ContentSource="Header" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ControlForegroundBrush}" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource BorderBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</controls:MetroWindow.Resources>
<controls:MetroWindow.RightWindowCommands>
<controls:WindowCommands>
<!-- Show version in title area; theme toggle moved into window content -->
<TextBlock Name="VersionTextBlock" Text="" VerticalAlignment="Center" Margin="8,0,0,0" />
</controls:WindowCommands>
</controls:MetroWindow.RightWindowCommands>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="3*" />
<!-- Make the live view/right side narrower -->
<ColumnDefinition Width="0.8*" />
</Grid.ColumnDefinitions>
<!-- Left: Tabs -->
<controls:MetroTabControl Grid.Column="0" Margin="0,0,10,0">
<controls:MetroTabItem>
<controls:MetroTabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CogOutline" Width="16" Height="16" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Generale" />
</StackPanel>
</controls:MetroTabItem.Header>
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="Percorsi" FontWeight="Bold" />
<StackPanel Margin="0,6,0,0">
<!-- Source path row: textbox with pick and open buttons aligned to the end -->
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Sorgente:" VerticalAlignment="Center" Margin="0,0,8,0" Grid.Column="0" />
<TextBox Text="{Binding SourcePath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="1" VerticalAlignment="Center" />
<Button Width="88" Margin="8,0,0,0" Command="{Binding SelectSourceFolderCommand}" Grid.Column="2">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="FolderOpenOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Scegli..." />
</StackPanel>
</Button>
<Button Width="56" Margin="8,0,0,0" Click="OpenSourceFolder_Click" Grid.Column="3">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="Folder" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Apri" />
</StackPanel>
</Button>
</Grid>
<!-- Destination path row -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Destinazione:" VerticalAlignment="Center" Margin="0,0,8,0" Grid.Column="0" />
<TextBox Text="{Binding DestinationPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="1" VerticalAlignment="Center" />
<Button Width="88" Margin="8,0,0,0" Command="{Binding SelectDestinationFolderCommand}" Grid.Column="2">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="FolderOpenOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Scegli..." />
</StackPanel>
</Button>
<Button Width="56" Margin="8,0,0,0" Click="OpenDestinationFolder_Click" Grid.Column="3">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="Folder" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Apri" />
</StackPanel>
</Button>
</Grid>
</StackPanel>
<TextBlock Text="Opzioni" FontWeight="Bold" Margin="0,12,0,0" />
<StackPanel Orientation="Vertical" Margin="0,6,0,0">
<CheckBox Content="Forza JPEG" IsChecked="{Binding ForceJpeg}" />
<CheckBox Content="Aggiorna sottodirectory" IsChecked="{Binding UpdateSubdirectories}" />
<CheckBox Content="Crea sottocartelle" IsChecked="{Binding CreateSubfolders}" />
<CheckBox Content="Sovrascrivi immagini" IsChecked="{Binding OverwriteImages}" />
</StackPanel>
<TextBlock Text="Elaborazione" FontWeight="Bold" Margin="0,12,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="Threads:" VerticalAlignment="Center" />
<TextBox Text="{Binding ThreadsCount, Mode=TwoWay}" Width="60" Margin="8,0,0,0" />
<TextBlock Text="Chunk:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding ChunkSize, Mode=TwoWay}" Width="60" Margin="8,0,0,0" />
</StackPanel>
<TextBlock Text="Divisione cartelle" FontWeight="Bold" Margin="0,12,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="File per cartella:" VerticalAlignment="Center" />
<TextBox Text="{Binding FilesPerFolder}" Width="60" Margin="8,0,0,0" />
<TextBlock Text="Suffisso:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding FolderSuffix}" Width="120" Margin="8,0,0,0" />
</StackPanel>
<TextBlock Text="Numerazione" FontWeight="Bold" Margin="0,12,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<RadioButton Content="Progressiva" IsChecked="{Binding UseProgressiveNumbering}" GroupName="Num" />
<RadioButton Content="Per file" IsChecked="{Binding UseFileNumbering}" GroupName="Num" Margin="8,0,0,0" />
<TextBlock Text="Cifre:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding CounterDigits}" Width="40" Margin="8,0,0,0" />
</StackPanel>
<TextBlock Text="Libreria Immagini" FontWeight="Bold" Margin="0,12,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<RadioButton Content="System.Graphics" IsChecked="{Binding UseSystemGraphics}" GroupName="Lib" />
<RadioButton Content="ImageSharp" IsChecked="{Binding UseImageSharp}" GroupName="Lib" Margin="8,0,0,0" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</controls:MetroTabItem>
<controls:MetroTabItem>
<controls:MetroTabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="FormatLetterCase" Width="16" Height="16" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Testo" />
</StackPanel>
</controls:MetroTabItem.Header>
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="Testo Orizzontale" FontWeight="Bold" />
<TextBox Text="{Binding HorizontalText, Mode=TwoWay}" />
<TextBlock Text="Testo Verticale" FontWeight="Bold" Margin="0,8,0,0" />
<TextBox Text="{Binding VerticalText, Mode=TwoWay}" AcceptsReturn="True" TextWrapping="Wrap" MinLines="4" VerticalScrollBarVisibility="Auto" />
<TextBlock Text="Font" FontWeight="Bold" Margin="0,8,0,0" />
<StackPanel Orientation="Horizontal">
<ComboBox ItemsSource="{Binding AvailableFonts}" SelectedItem="{Binding FontName}" Width="250" />
<TextBox Text="{Binding FontSize}" Width="60" Margin="8,0,0,0" />
<CheckBox Content="Grassetto" IsChecked="{Binding FontBold}" Margin="8,0,0,0" />
</StackPanel>
<TextBlock Text="Colore testo" FontWeight="Bold" Margin="0,8,0,0" />
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding TextColorRGB}" Width="120" />
<Button Content="Seleziona colore" Command="{Binding SelectColorCommand}" Margin="8,0,0,0" />
<!-- MahApps color picker for direct color selection -->
<controls:ColorPicker SelectedColor="{Binding TextColor}" Width="160" Margin="8,0,0,0" />
</StackPanel>
<TextBlock Text="Dimensioni verticale" FontWeight="Bold" Margin="0,8,0,0" />
<StackPanel Orientation="Horizontal">
<TextBlock Text="Size:" VerticalAlignment="Center" />
<TextBox Text="{Binding VerticalTextSize}" Width="60" Margin="8,0,0,0" />
<TextBlock Text="Margin:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding VerticalTextMargin}" Width="60" Margin="8,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="Trasparenza testo:" VerticalAlignment="Center" />
<TextBox Text="{Binding TextTransparency}" Width="60" Margin="8,0,0,0" />
<TextBlock Text="Margine testo:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding TextMargin}" Width="60" Margin="8,0,0,0" />
</StackPanel>
<!-- Tempo Gara section moved from Foto tab -->
<TextBlock Text="Tempo Gara" FontWeight="Bold" Margin="0,12,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<CheckBox Content="Aggiungi Orario" IsChecked="{Binding AddTime}" />
<CheckBox Content="Aggiungi tempo gara" IsChecked="{Binding AddRaceTime}" Margin="12,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="Partenza:" VerticalAlignment="Center" />
<controls:DateTimePicker SelectedDateTime="{Binding RaceTime}" IsEnabled="{Binding AddRaceTime}" Margin="8,0,0,0" Width="200" />
<TextBox Text="{Binding TimeLabel}" Width="220" Margin="12,0,0,0" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</controls:MetroTabItem>
<controls:MetroTabItem>
<controls:MetroTabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CameraFrontVariant" Width="16" Height="16" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Foto" />
</StackPanel>
</controls:MetroTabItem.Header>
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="Dimensioni foto grandi" FontWeight="Bold" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBox Text="{Binding PhotoBigWidth}" Width="80" />
<TextBox Text="{Binding PhotoBigHeight}" Width="80" Margin="8,0,0,0" />
</StackPanel>
<TextBlock Text="Opzioni foto" FontWeight="Bold" Margin="0,8,0,0" />
<StackPanel Orientation="Vertical" Margin="0,6,0,0">
<CheckBox Content="Mantieni dimensioni originali" IsChecked="{Binding KeepOriginalDimensions}" />
<CheckBox Content="Rotazione automatica" IsChecked="{Binding AutomaticRotation}" />
</StackPanel>
<TextBlock Text="JPEG" FontWeight="Bold" Margin="0,8,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="Qualità:" VerticalAlignment="Center" />
<TextBox Text="{Binding JpegQuality}" Width="60" Margin="8,0,0,0" />
<TextBlock Text="Miniature Qualità:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding JpegQualityThumbnail}" Width="60" Margin="8,0,0,0" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</controls:MetroTabItem>
<controls:MetroTabItem>
<controls:MetroTabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Image" Width="16" Height="16" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Miniature" />
</StackPanel>
</controls:MetroTabItem.Header>
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="Miniature" FontWeight="Bold" />
<CheckBox Content="Crea miniature" IsChecked="{Binding CreateThumbnails}" Margin="0,6,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="Prefisso:" VerticalAlignment="Center" />
<TextBox Text="{Binding ThumbnailPrefix}" Width="120" Margin="8,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBox Text="{Binding ThumbnailWidth}" Width="80" />
<TextBox Text="{Binding ThumbnailHeight}" Width="80" Margin="8,0,0,0" />
</StackPanel>
<!-- New unified thumbnail mode selector (Italian labels) -->
<StackPanel Orientation="Vertical" Margin="0,8,0,0">
<TextBlock Text="Modalità miniature:" VerticalAlignment="Center" />
<ComboBox SelectedIndex="{Binding ThumbnailOptionIndex, Mode=TwoWay}" Width="220" Margin="0,6,0,0">
<ComboBoxItem>Nessuna</ComboBoxItem>
<ComboBoxItem>Aggiungi scritta</ComboBoxItem>
<ComboBoxItem>Nome file</ComboBoxItem>
<ComboBoxItem>Aggiungi orario</ComboBoxItem>
<ComboBoxItem>Nome+Orario</ComboBoxItem>
<ComboBoxItem>Tempo gara</ComboBoxItem>
</ComboBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
</controls:MetroTabItem>
<controls:MetroTabItem>
<controls:MetroTabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ImageFilterCenterFocus" Width="16" Height="16" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Logo" />
</StackPanel>
</controls:MetroTabItem.Header>
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="Logo" FontWeight="Bold" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<Button Command="{Binding SelectLogoFileCommand}">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ImageOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Seleziona logo" />
</StackPanel>
</Button>
<TextBlock Text="{Binding LogoFile}" Margin="8,0,0,0" VerticalAlignment="Center" Width="250" TextTrimming="CharacterEllipsis" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<Image Name="LogoPreview" Width="160" Height="160" Stretch="Uniform" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBox Text="{Binding LogoWidth}" Width="80" />
<TextBox Text="{Binding LogoHeight}" Width="80" Margin="8,0,0,0" />
</StackPanel>
<CheckBox Content="Aggiungi logo" IsChecked="{Binding AddLogo}" Margin="0,8,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="Margine:" VerticalAlignment="Center" />
<TextBox Text="{Binding LogoMargin}" Width="80" Margin="8,0,0,0" />
<TextBlock Text="Trasparenza:" VerticalAlignment="Center" Margin="12,0,0,0" />
<TextBox Text="{Binding LogoTransparency}" Width="60" Margin="8,0,0,0" />
<Button Command="{Binding SelectTransparentColorCommand}" Margin="8,0,0,0">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="PaletteOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Seleziona trasparente" />
</StackPanel>
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="Posizione:" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding HorizontalAlignments}" SelectedItem="{Binding LogoHorizontalPosition}" Width="120" Margin="8,0,0,0" />
<ComboBox ItemsSource="{Binding VerticalPositions}" SelectedItem="{Binding LogoVerticalPosition}" Width="120" Margin="8,0,0,0" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</controls:MetroTabItem>
<controls:MetroTabItem>
<controls:MetroTabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Robot" Width="16" Height="16" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="AI" />
</StackPanel>
</controls:MetroTabItem.Header>
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="AI / OCR" FontWeight="Bold" />
<CheckBox Content="Estrai numeri dalle immagini" IsChecked="{Binding ExtractNumbers}" Margin="0,8,0,0" />
<TextBlock Text="Modelli" FontWeight="Bold" Margin="0,12,0,0" />
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Cartella modelli:" VerticalAlignment="Center" Margin="0,0,8,0" Grid.Column="0" />
<TextBox Text="{Binding ModelsFolderPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="1" VerticalAlignment="Center" />
<Button Width="88" Margin="8,0,0,0" Command="{Binding SelectModelsFolderCommand}" Grid.Column="2">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="FolderSearchOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Scegli..." />
</StackPanel>
</Button>
<Button Width="56" Margin="8,0,0,0" Click="OpenModelsFolder_Click" Grid.Column="3">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="Folder" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Apri" />
</StackPanel>
</Button>
</Grid>
<TextBlock Text="Output CSV" FontWeight="Bold" Margin="0,12,0,0" />
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Percorso CSV:" VerticalAlignment="Center" Margin="0,0,8,0" Grid.Column="0" />
<TextBox Text="{Binding CsvOutputPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="1" VerticalAlignment="Center" />
<Button Width="88" Margin="8,0,0,0" Command="{Binding SelectCsvOutputCommand}" Grid.Column="2">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="FileFindOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Scegli..." />
</StackPanel>
</Button>
<Button Width="56" Margin="8,0,0,0" Click="OpenCsvOutputFolder_Click" Grid.Column="3">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="Folder" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Apri" />
</StackPanel>
</Button>
</Grid>
<TextBlock Text="Anteprima risultati" FontWeight="Bold" Margin="0,12,0,0" />
<DataGrid ItemsSource="{Binding PreviewResults}" IsReadOnly="True" AutoGenerateColumns="False" Height="200" Margin="0,6,0,0">
<DataGrid.Columns>
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
<DataGridTextColumn Header="Text" Binding="{Binding Text}" Width="2*" />
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</ScrollViewer>
</controls:MetroTabItem>
</controls:MetroTabControl>
<!-- Right: Controls and live info -->
<StackPanel Grid.Column="1" Orientation="Vertical">
<!-- Compact theme toggle panel (icon-only) aligned right -->
<StackPanel HorizontalAlignment="Stretch" Margin="0,0,0,12">
<Button Width="24" Height="24" Click="ToggleTheme_Click" ToolTip="Cambia tema" HorizontalAlignment="Right" Padding="2">
<iconPacks:PackIconMaterial Kind="ThemeLightDark" Width="12" Height="12" Foreground="{StaticResource AccentBrush}" />
</Button>
</StackPanel>
<Border BorderBrush="#DDD" BorderThickness="1" Padding="8" MaxWidth="280">
<!-- Buttons and status live info inside the bordered panel -->
<StackPanel>
<!-- Buttons stacked vertically as requested -->
<StackPanel Orientation="Vertical" HorizontalAlignment="Stretch">
<Button Width="120" Margin="0,0,0,8" Command="{Binding LoadSettingsCommand}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="FolderUploadOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Carica" />
</StackPanel>
</Button>
<Button Width="120" Margin="0,0,0,8" Command="{Binding SaveSettingsCommand}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="ContentSaveOutline" Width="14" Height="14" Foreground="{StaticResource AccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="Salva" />
</StackPanel>
</Button>
<Button Width="120" Height="36" Margin="0,6,0,8" Command="{Binding ProcessImagesCommand}" IsEnabled="{Binding UiEnabled}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="PlayCircleOutline" Width="14" Height="14" Foreground="Green" Margin="0,0,6,0" />
<TextBlock Text="Avvia" />
</StackPanel>
</Button>
<Button Width="120" Height="36" Command="{Binding AsyncCancelOperationCommand}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="StopCircleOutline" Width="14" Height="14" Foreground="Red" Margin="0,0,6,0" />
<TextBlock Text="Stop" />
</StackPanel>
</Button>
</StackPanel>
<Separator Margin="0,12,0,12" />
<TextBlock Text="Stato" FontWeight="Bold" />
<TextBlock Text="{Binding ProcessingStatus}" TextWrapping="Wrap" />
<TextBlock Text="Progresso" FontWeight="Bold" Margin="0,8,0,0" />
<ProgressBar Minimum="0" Maximum="{Binding ProgressBarMaximum}" Value="{Binding ProgressBarValue}" Height="20" />
<TextBlock Margin="0,6,0,0">
<Run Text="{Binding ProcessedImagesCount}" />
<Run Text=" / " />
<Run Text="{Binding TotalImagesCount}" />
</TextBlock>
<TextBlock Text="Velocità" FontWeight="Bold" Margin="0,8,0,0" />
<TextBlock Text="{Binding SpeedCounter}" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<!-- Status bar removed; version now shown in the title commands area -->
</Grid>
</controls:MetroWindow>

View file

@ -1,383 +0,0 @@
#if WINDOWS
using System.Windows;
using MahApps.Metro.Controls;
using ControlzEx.Theming;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.IO;
using System.Windows.Media.Imaging;
using System.Diagnostics;
using Microsoft.Win32;
using System.Windows.Forms;
namespace ImageCatalog_2
{
public partial class MainWindow : MetroWindow
{
private readonly DataModel _model;
private bool _isDarkTheme = false;
public MainWindow(DataModel model)
{
InitializeComponent();
_model = model;
DataContext = _model;
// Set product version in status bar (use ProductVersion rather than AssemblyVersion)
try
{
var entry = System.Reflection.Assembly.GetEntryAssembly();
string version = string.Empty;
if (entry is not null && !string.IsNullOrEmpty(entry.Location))
{
try
{
version = FileVersionInfo.GetVersionInfo(entry.Location).ProductVersion ?? string.Empty;
}
catch { }
}
if (string.IsNullOrWhiteSpace(version))
{
// fallback to assembly version
version = entry?.GetName().Version?.ToString() ?? string.Empty;
}
VersionTextBlock.Text = string.IsNullOrWhiteSpace(version) ? string.Empty : $"v{version}";
}
catch { }
// Ensure MahApps resource dictionaries are loaded so chrome/styles are available
EnsureMahAppsResourcesLoaded();
// Apply theme based on user preference or system setting (default to light)
ApplyTheme(isDark: false);
// Subscribe to DataModel events that require UI dialogs
_model.SelectSourceFolderRequested += Model_SelectSourceFolderRequested;
_model.SelectDestinationFolderRequested += Model_SelectDestinationFolderRequested;
_model.SelectLogoFileRequested += Model_SelectLogoFileRequested;
_model.SaveSettingsRequested += Model_SaveSettingsRequested;
_model.LoadSettingsRequested += Model_LoadSettingsRequested;
_model.SelectColorRequested += Model_SelectColorRequested;
_model.SelectTransparentColorRequested += Model_SelectTransparentColorRequested;
_model.SelectModelsFolderRequested += Model_SelectModelsFolderRequested;
_model.SelectCsvOutputRequested += Model_SelectCsvOutputRequested;
// Watch for logo changes to update preview
_model.PropertyChanged += Model_PropertyChanged;
}
private void ApplyTheme(bool isDark)
{
try
{
var rd = isDark ? (ResourceDictionary)Resources["DarkTheme"] : (ResourceDictionary)Resources["LightTheme"];
foreach (var key in rd.Keys)
{
// If the theme dictionary uses suffixed keys (e.g. "WindowBackgroundBrush.Dark"),
// map them to the base key ("WindowBackgroundBrush") so existing DynamicResource lookups update.
string outKey = key?.ToString() ?? string.Empty;
if (outKey.EndsWith(".Light", StringComparison.OrdinalIgnoreCase))
outKey = outKey.Substring(0, outKey.Length - ".Light".Length);
else if (outKey.EndsWith(".Dark", StringComparison.OrdinalIgnoreCase))
outKey = outKey.Substring(0, outKey.Length - ".Dark".Length);
Resources[outKey] = rd[key];
}
}
catch
{
// ignore theme failures
}
}
private void Model_SelectModelsFolderRequested(object? sender, EventArgs e)
{
var dlg = new System.Windows.Forms.FolderBrowserDialog();
var starting = string.IsNullOrWhiteSpace(_model.ModelsFolderPath) ? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) : _model.ModelsFolderPath;
dlg.SelectedPath = starting;
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_model.ModelsFolderPath = dlg.SelectedPath + Path.DirectorySeparatorChar;
}
}
private void OpenModelsFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var path = _model.ModelsFolderPath;
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (File.Exists(path))
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
if (Directory.Exists(path))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true });
return;
}
}
catch { }
}
private void Model_SelectCsvOutputRequested(object? sender, EventArgs e)
{
var dlg = new Microsoft.Win32.SaveFileDialog();
dlg.Filter = "CSV file (*.csv)|*.csv|All files (*.*)|*.*";
if (!string.IsNullOrWhiteSpace(_model.CsvOutputPath)) dlg.FileName = _model.CsvOutputPath;
var result = dlg.ShowDialog(this);
if (result == true)
{
_model.CsvOutputPath = dlg.FileName;
}
}
private void OpenCsvOutputFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var path = _model.CsvOutputPath;
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (File.Exists(path))
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = dir, UseShellExecute = true });
return;
}
}
catch { }
}
private void Model_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e is null || string.IsNullOrWhiteSpace(e.PropertyName)) return;
if (e.PropertyName == nameof(_model.LogoFile))
{
UpdateLogoPreview(_model.LogoFile);
}
}
private void Model_SelectSourceFolderRequested(object? sender, EventArgs e)
{
var dlg = new System.Windows.Forms.FolderBrowserDialog();
var starting = string.IsNullOrWhiteSpace(_model.SourcePath) ? Environment.GetFolderPath(Environment.SpecialFolder.MyPictures) : _model.SourcePath;
dlg.SelectedPath = starting;
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_model.SourcePath = dlg.SelectedPath + Path.DirectorySeparatorChar;
}
}
private void OpenSourceFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var path = _model.SourcePath;
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (File.Exists(path))
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
if (Directory.Exists(path))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true });
return;
}
}
catch (Exception ex)
{
// ignore for now, or could show a message
}
}
private void Model_SelectDestinationFolderRequested(object? sender, EventArgs e)
{
var dlg = new System.Windows.Forms.FolderBrowserDialog();
var starting = string.IsNullOrWhiteSpace(_model.DestinationPath) ? Environment.GetFolderPath(Environment.SpecialFolder.MyPictures) : _model.DestinationPath;
dlg.SelectedPath = starting;
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_model.DestinationPath = dlg.SelectedPath + Path.DirectorySeparatorChar;
}
}
private void OpenDestinationFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var path = _model.DestinationPath;
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (File.Exists(path))
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
if (Directory.Exists(path))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true });
return;
}
}
catch (Exception ex)
{
// ignore for now
}
}
private void Model_SelectLogoFileRequested(object? sender, EventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog();
dlg.Filter = "Image Files|*.jpg;*.jpeg;*.png;*.bmp;*.gif";
if (!string.IsNullOrWhiteSpace(_model.LogoFile)) dlg.FileName = _model.LogoFile;
var result = dlg.ShowDialog(this);
if (result == true)
{
_model.LogoFile = dlg.FileName;
}
}
private async void Model_SaveSettingsRequested(object? sender, string filePath)
{
var dlg = new Microsoft.Win32.SaveFileDialog();
dlg.Filter = "Setup (*.xml)|*.xml|All valid files (*.*)|*.*";
var result = dlg.ShowDialog(this);
if (result == true)
{
await _model.SaveSettingsToFileAsync(dlg.FileName);
}
}
private async void Model_LoadSettingsRequested(object? sender, string filePath)
{
var dlg = new Microsoft.Win32.OpenFileDialog();
dlg.Filter = "Setup (*.xml)|*.xml|All valid files (*.*)|*.*";
var result = dlg.ShowDialog(this);
if (result == true)
{
await _model.LoadSettingsFromFileAsync(dlg.FileName);
}
}
private void Model_SelectColorRequested(object? sender, EventArgs e)
{
var dlg = new System.Windows.Forms.ColorDialog { AllowFullOpen = true };
if (!string.IsNullOrWhiteSpace(_model.TextColorRGB))
{
try { dlg.Color = System.Drawing.ColorTranslator.FromHtml(_model.TextColorRGB); } catch { }
}
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_model.TextColorRGB = System.Drawing.ColorTranslator.ToHtml(dlg.Color);
}
}
private void Model_SelectTransparentColorRequested(object? sender, EventArgs e)
{
var dlg = new System.Windows.Forms.ColorDialog { AllowFullOpen = true };
try { dlg.Color = System.Drawing.ColorTranslator.FromHtml(_model.TransparentColor); } catch { }
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_model.TransparentColor = System.Drawing.ColorTranslator.ToHtml(dlg.Color);
}
}
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
{
ToggleTheme();
}
private void ToggleTheme()
{
try
{
_isDarkTheme = !_isDarkTheme;
// Use MahApps ThemeManager to change the application theme (handles chrome and brushes)
try
{
var themeName = _isDarkTheme ? "Dark.Blue" : "Light.Blue";
ThemeManager.Current.ChangeTheme(System.Windows.Application.Current, themeName);
}
catch
{
// Fall back silently if ThemeManager isn't available
}
// Still apply local resource overrides so any app-specific keys update
ApplyTheme(_isDarkTheme);
}
catch
{
// ignore toggle failures
}
}
private void EnsureMahAppsResourcesLoaded()
{
try
{
var app = System.Windows.Application.Current;
if (app is null)
return;
var mds = app.Resources.MergedDictionaries;
// Helper to add if missing
void AddIfMissing(string uriString)
{
if (!mds.Any(d => d.Source is not null && d.Source.OriginalString.Equals(uriString, StringComparison.OrdinalIgnoreCase)))
{
mds.Add(new ResourceDictionary { Source = new Uri(uriString) });
}
}
AddIfMissing("pack://application:,,,/MahApps.Metro;component/Styles/Controls.xaml");
AddIfMissing("pack://application:,,,/MahApps.Metro;component/Styles/Fonts.xaml");
// Ensure a default theme is present
if (!mds.Any(d => d.Source is not null && d.Source.OriginalString.IndexOf("/MahApps.Metro;component/Styles/Themes/", StringComparison.OrdinalIgnoreCase) >= 0))
{
AddIfMissing("pack://application:,,,/MahApps.Metro;component/Styles/Themes/Light.Blue.xaml");
_isDarkTheme = false;
}
}
catch
{
// ignore; styling will fallback to local resources
}
}
private void UpdateLogoPreview(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
LogoPreview.Source = null;
return;
}
try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.UriSource = new Uri(path);
bitmap.EndInit();
LogoPreview.Source = bitmap;
}
catch
{
LogoPreview.Source = null;
}
}
}
}
#endif

View file

@ -282,6 +282,26 @@ namespace ImageCatalog_2.Models
[XmlElement("AI_FaceOutputFolderPath")]
public string FaceOutputFolderPath { get; set; } = string.Empty;
[JsonPropertyName("FaceRecursive")]
[XmlElement("AI_FaceRecursive")]
public bool FaceRecursive { get; set; }
[JsonPropertyName("FaceIncludeThumbnails")]
[XmlElement("AI_FaceIncludeThumbnails")]
public bool FaceIncludeThumbnails { get; set; }
[JsonPropertyName("FaceParallelism")]
[XmlElement("AI_FaceParallelism")]
public int FaceParallelism { get; set; } = 3;
[JsonPropertyName("FaceMinSize")]
[XmlElement("AI_FaceMinSize")]
public int FaceMinSize { get; set; } = 35;
[JsonPropertyName("FaceUpsample")]
[XmlElement("AI_FaceUpsample")]
public bool FaceUpsample { get; set; } = true;
// Race upload settings
[JsonPropertyName("ApiLogin")]
[XmlElement("RaceUpload_Login")]

View file

@ -11,7 +11,6 @@ using Microsoft.Extensions.Logging.Console;
using System.IO;
using Microsoft.Extensions.Options;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
namespace ImageCatalog_2;
@ -21,6 +20,9 @@ static class Program
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AllocConsole();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);
@ -36,6 +38,12 @@ static class Program
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(int dwProcessId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? handlerRoutine, bool add);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateFile(
string lpFileName,
@ -48,6 +56,9 @@ static class Program
private const uint GENERIC_WRITE = 0x40000000;
private const uint OPEN_EXISTING = 3;
private const uint CTRL_C_EVENT = 0;
private delegate bool ConsoleCtrlDelegate(uint ctrlType);
private static void RedirectConsoleOutput()
{
@ -58,6 +69,12 @@ static class Program
Console.SetOut(standardOutput);
Console.SetError(standardOutput);
}
internal static bool TrySendConsoleInterrupt(int processId)
{
_ = processId;
return false;
}
#endif
public static IServiceProvider ServiceProvider { get; private set; } = default!;
@ -71,10 +88,6 @@ static class Program
static void Main(string[] args)
{
#if WINDOWS
System.Windows.Forms.Application.SetHighDpiMode(System.Windows.Forms.HighDpiMode.SystemAware);
System.Windows.Forms.Application.EnableVisualStyles();
System.Windows.Forms.Application.SetCompatibleTextRenderingDefault(false);
AllocConsole();
RedirectConsoleOutput();
#endif
@ -84,59 +97,7 @@ static class Program
ServiceProvider = serviceCollection.BuildServiceProvider();
var serviceProvider = ServiceProvider;
// Determine UI based on command line. Default: WinForms. Use --wpf for WPF, --avalonia for Avalonia.
bool useWpf = args is not null && Array.Exists(args, a => string.Equals(a, "--wpf", StringComparison.OrdinalIgnoreCase));
bool useAvalonia = args is not null && Array.Exists(args, a => string.Equals(a, "--avalonia", StringComparison.OrdinalIgnoreCase));
if (useAvalonia)
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args ?? Array.Empty<string>());
return;
}
#if WINDOWS
if (useWpf)
{
var wpfApp = new System.Windows.Application();
try
{
wpfApp.Resources.MergedDictionaries.Add(new System.Windows.ResourceDictionary { Source = new Uri("pack://application:,,,/MahApps.Metro;component/Styles/Controls.xaml") });
wpfApp.Resources.MergedDictionaries.Add(new System.Windows.ResourceDictionary { Source = new Uri("pack://application:,,,/MahApps.Metro;component/Styles/Fonts.xaml") });
wpfApp.Resources.MergedDictionaries.Add(new System.Windows.ResourceDictionary { Source = new Uri("pack://application:,,,/MahApps.Metro;component/Styles/Themes/Light.Blue.xaml") });
try
{
ControlzEx.Theming.ThemeManager.Current.ChangeTheme(wpfApp, "Light.Blue");
}
catch
{
// ignore if ThemeManager API isn't present
}
}
catch
{
// If resources fail to load, continue silently
}
var wpfMain = serviceProvider.GetService(typeof(ImageCatalog_2.MainWindow)) as ImageCatalog_2.MainWindow;
if (wpfMain is not null)
{
wpfApp.Run(wpfMain);
return;
}
// If WPF was requested but not available, fall through to WinForms.
}
// Default / fallback to WinForms UI
var mainForm = serviceProvider.GetRequiredService<MainForm>();
System.Windows.Forms.Application.Run(mainForm);
#else
// On non-Windows, Avalonia is the only available UI
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args ?? Array.Empty<string>());
#endif
}
private static void ConfigureServices(ServiceCollection services)
@ -189,11 +150,6 @@ static class Program
services.AddTransient<AvaloniaMainWindow>();
#if WINDOWS
services.AddTransient<MainForm>();
services.AddTransient<ImageCatalog_2.MainWindow>();
#endif
services.AddSingleton<MaddoShared.IVersionProvider, MaddoShared.VersionProvider>();
services.AddLogging(configure =>

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MaddoShared.Tests")]

View file

@ -6,18 +6,12 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
#if WINDOWS
using System.Windows.Forms;
#endif
namespace ImageCatalog_2
{
public class ViewModelBase : INotifyPropertyChanged
{
private readonly SynchronizationContext? _synchronizationContext;
#if WINDOWS
private Control? _control;
#endif
protected ViewModelBase()
{
@ -25,17 +19,6 @@ namespace ImageCatalog_2
_synchronizationContext = SynchronizationContext.Current;
}
/// <summary>
/// Set a Control to use for thread marshalling in WinForms applications.
/// This is required for proper cross-thread handling with data binding.
/// </summary>
#if WINDOWS
public void SetControl(Control control)
{
_control = control;
}
#endif
public event PropertyChangedEventHandler? PropertyChanged;
// This method is called by the Set accessor of each property.
@ -46,22 +29,6 @@ namespace ImageCatalog_2
if (PropertyChanged == null)
return;
#if WINDOWS
// If we have a Control reference (WinForms), use Control.Invoke for proper marshalling
if (_control != null)
{
if (_control.InvokeRequired)
{
_control.Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
}
else
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
// Fallback to SynchronizationContext if available
else
#endif
if (_synchronizationContext != null && SynchronizationContext.Current != _synchronizationContext)
{
// We're on a different thread, marshal to the UI thread

View file

@ -60,6 +60,116 @@ public class AiSettingsViewModel : ViewModelBase
}
}
private bool _faceRecursive;
public bool FaceRecursive
{
get => _faceRecursive;
set
{
_faceRecursive = value;
NotifyPropertyChanged();
}
}
private bool _faceIncludeThumbnails;
public bool FaceIncludeThumbnails
{
get => _faceIncludeThumbnails;
set
{
_faceIncludeThumbnails = value;
NotifyPropertyChanged();
}
}
private int _faceParallelism = 3;
public int FaceParallelism
{
get => _faceParallelism;
set
{
_faceParallelism = value;
NotifyPropertyChanged();
}
}
private int _faceMinSize = 35;
public int FaceMinSize
{
get => _faceMinSize;
set
{
_faceMinSize = value;
NotifyPropertyChanged();
}
}
private bool _faceUpsample = true;
public bool FaceUpsample
{
get => _faceUpsample;
set
{
_faceUpsample = value;
NotifyPropertyChanged();
}
}
private bool _faceGpuOptionEnabled;
public bool FaceGpuOptionEnabled
{
get => _faceGpuOptionEnabled;
set
{
_faceGpuOptionEnabled = value;
NotifyPropertyChanged();
}
}
private bool _useFaceGpu;
public bool UseFaceGpu
{
get => _useFaceGpu;
set
{
_useFaceGpu = value;
NotifyPropertyChanged();
}
}
private bool _isFaceEncoderRunning;
public bool IsFaceEncoderRunning
{
get => _isFaceEncoderRunning;
set
{
_isFaceEncoderRunning = value;
NotifyPropertyChanged();
}
}
private string _faceStatusMessage = string.Empty;
public string FaceStatusMessage
{
get => _faceStatusMessage;
set
{
_faceStatusMessage = value;
NotifyPropertyChanged();
}
}
private string _faceCommandOutput = string.Empty;
public string FaceCommandOutput
{
get => _faceCommandOutput;
set
{
_faceCommandOutput = value;
NotifyPropertyChanged();
}
}
private double _aiProgress;
public double AiProgress
{