Browse Source

Merge branch 'dev' into inference-modules

pull/333/head
Ionite 1 year ago committed by GitHub
parent
commit
bac5a51f48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      CHANGELOG.md
  2. 5
      StabilityMatrix.Avalonia/App.axaml.cs
  3. 3
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  4. 6
      StabilityMatrix.Avalonia/DesignData/MockImageIndexService.cs
  5. 14
      StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs
  6. 27
      StabilityMatrix.Avalonia/Helpers/ImageProcessor.cs
  7. 11
      StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs
  8. 18
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  9. 6
      StabilityMatrix.Avalonia/Languages/Resources.resx
  10. 73
      StabilityMatrix.Avalonia/Models/Inference/FileNameFormat.cs
  11. 16
      StabilityMatrix.Avalonia/Models/Inference/FileNameFormatPart.cs
  12. 192
      StabilityMatrix.Avalonia/Models/Inference/FileNameFormatProvider.cs
  13. 8
      StabilityMatrix.Avalonia/Models/Inference/FileNameFormatVar.cs
  14. 7
      StabilityMatrix.Avalonia/Models/PackageOutputCategory.cs
  15. 45
      StabilityMatrix.Avalonia/Services/InferenceClientManager.cs
  16. 3
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  17. 193
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
  18. 1
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs
  19. 4
      StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs
  20. 3
      StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs
  21. 78
      StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs
  22. 265
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  23. 31
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  24. 64
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  25. 14
      StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml
  26. 145
      StabilityMatrix.Avalonia/Views/OutputsPage.axaml
  27. 14
      StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs
  28. 10
      StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml
  29. 46
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  30. 9
      StabilityMatrix.Core/Helper/EventManager.cs
  31. 29
      StabilityMatrix.Core/Helper/SharedFolders.cs
  32. 5
      StabilityMatrix.Core/Models/Database/LocalImageFile.cs
  33. 16
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  34. 20
      StabilityMatrix.Core/Models/GenerationParameters.cs
  35. 1
      StabilityMatrix.Core/Models/InstalledPackage.cs
  36. 13
      StabilityMatrix.Core/Models/Packages/A3WebUI.cs
  37. 32
      StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
  38. 11
      StabilityMatrix.Core/Models/Packages/BasePackage.cs
  39. 5
      StabilityMatrix.Core/Models/Packages/ComfyUI.cs
  40. 8
      StabilityMatrix.Core/Models/Packages/DankDiffusion.cs
  41. 5
      StabilityMatrix.Core/Models/Packages/Fooocus.cs
  42. 5
      StabilityMatrix.Core/Models/Packages/FooocusMre.cs
  43. 7
      StabilityMatrix.Core/Models/Packages/InvokeAI.cs
  44. 18
      StabilityMatrix.Core/Models/Packages/UnknownPackage.cs
  45. 13
      StabilityMatrix.Core/Models/Packages/VladAutomatic.cs
  46. 10
      StabilityMatrix.Core/Models/Packages/VoltaML.cs
  47. 5
      StabilityMatrix.Core/Models/Settings/Settings.cs
  48. 12
      StabilityMatrix.Core/Models/SharedOutputType.cs
  49. 28
      StabilityMatrix.Tests/Avalonia/FileNameFormatProviderTests.cs
  50. 24
      StabilityMatrix.Tests/Avalonia/FileNameFormatTests.cs

7
CHANGELOG.md

@ -5,7 +5,12 @@ All notable changes to Stability Matrix will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).
## v2.5.4
## v2.6.0
### Added
- Added "Output Sharing" option for all packages in the three-dots menu on the Packages page
- This will link the package's output folders to the relevant subfolders in the "Outputs" directory
- When a package only has a generic "outputs" folder, all generated images from that package will be linked to the "Outputs\Text2Img" folder when this option is enabled
- Added "Outputs" page for viewing generated images from any package, or the shared output folder
### Fixed
- Fixed crash when clicking Inference gallery image after the image is deleted externally in file explorer

5
StabilityMatrix.Avalonia/App.axaml.cs

@ -262,7 +262,8 @@ public sealed class App : Application
.AddSingleton<LaunchPageViewModel>()
.AddSingleton<ProgressManagerViewModel>()
.AddSingleton<InferenceViewModel>()
.AddSingleton<ProgressManagerViewModel>();
.AddSingleton<ProgressManagerViewModel>()
.AddSingleton<OutputsPageViewModel>();
services.AddSingleton<MainWindowViewModel>(
provider =>
@ -281,6 +282,7 @@ public sealed class App : Application
provider.GetRequiredService<PackageManagerViewModel>(),
provider.GetRequiredService<CheckpointsPageViewModel>(),
provider.GetRequiredService<CheckpointBrowserViewModel>(),
provider.GetRequiredService<OutputsPageViewModel>()
},
FooterPages = { provider.GetRequiredService<SettingsViewModel>() }
}
@ -393,6 +395,7 @@ public sealed class App : Application
services.AddSingleton<ProgressManagerPage>();
services.AddSingleton<InferencePage>();
services.AddSingleton<NewCheckpointsPage>();
services.AddSingleton<OutputsPage>();
// Inference tabs
services.AddTransient<InferenceTextToImageView>();

3
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -336,6 +336,9 @@ public static class DesignData
public static LaunchPageViewModel LaunchPageViewModel =>
Services.GetRequiredService<LaunchPageViewModel>();
public static OutputsPageViewModel OutputsPageViewModel =>
Services.GetRequiredService<OutputsPageViewModel>();
public static PackageManagerViewModel PackageManagerViewModel
{
get

6
StabilityMatrix.Avalonia/DesignData/MockImageIndexService.cs

@ -27,16 +27,22 @@ public class MockImageIndexService : IImageIndexService
{
RelativePath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/4a7e00a7-6f18-42d4-87c0-10e792df2640/width=1152",
AbsolutePath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/4a7e00a7-6f18-42d4-87c0-10e792df2640/width=1152",
},
new()
{
RelativePath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1024",
AbsolutePath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1024",
},
new()
{
RelativePath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/16588c94-6595-4be9-8806-d7e6e22d198c/width=1152",
AbsolutePath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/16588c94-6595-4be9-8806-d7e6e22d198c/width=1152",
}
}
);

14
StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs

@ -282,20 +282,16 @@ public static class ComfyNodeBuilderExtensions
builder.Connections.ImageSize = builder.Connections.LatentSize;
}
var saveImage = builder.Nodes.AddNamedNode(
var previewImage = builder.Nodes.AddNamedNode(
new NamedComfyNode("SaveImage")
{
ClassType = "SaveImage",
Inputs = new Dictionary<string, object?>
{
["filename_prefix"] = "Inference/TextToImage",
["images"] = builder.Connections.Image
}
ClassType = "PreviewImage",
Inputs = new Dictionary<string, object?> { ["images"] = builder.Connections.Image }
}
);
builder.Connections.OutputNodes.Add(saveImage);
builder.Connections.OutputNodes.Add(previewImage);
return saveImage.Name;
return previewImage.Name;
}
}

27
StabilityMatrix.Avalonia/Helpers/ImageProcessor.cs

@ -13,8 +13,10 @@ public static class ImageProcessor
/// </summary>
public static (int rows, int columns) GetGridDimensionsFromImageCount(int count)
{
if (count <= 1) return (1, 1);
if (count == 2) return (1, 2);
if (count <= 1)
return (1, 1);
if (count == 2)
return (1, 2);
// Prefer one extra row over one extra column,
// the row count will be the floor of the square root
@ -24,10 +26,11 @@ public static class ImageProcessor
return (rows, columns);
}
public static SKImage CreateImageGrid(
IReadOnlyList<SKImage> images,
int spacing = 0)
public static SKImage CreateImageGrid(IReadOnlyList<SKImage> images, int spacing = 0)
{
if (images.Count == 0)
throw new ArgumentException("Must have at least one image");
var (rows, columns) = GetGridDimensionsFromImageCount(images.Count);
var singleWidth = images[0].Width;
@ -36,17 +39,20 @@ public static class ImageProcessor
// Make output image
using var output = new SKBitmap(
singleWidth * columns + spacing * (columns - 1),
singleHeight * rows + spacing * (rows - 1));
singleHeight * rows + spacing * (rows - 1)
);
// Draw images
using var canvas = new SKCanvas(output);
foreach (var (row, column) in
Enumerable.Range(0, rows).Product(Enumerable.Range(0, columns)))
foreach (
var (row, column) in Enumerable.Range(0, rows).Product(Enumerable.Range(0, columns))
)
{
// Stop if we have drawn all images
var index = row * columns + column;
if (index >= images.Count) break;
if (index >= images.Count)
break;
// Get image
var image = images[index];
@ -56,7 +62,8 @@ public static class ImageProcessor
singleWidth * column + spacing * column,
singleHeight * row + spacing * row,
singleWidth * column + spacing * column + image.Width,
singleHeight * row + spacing * row + image.Height);
singleHeight * row + spacing * row + image.Height
);
canvas.DrawImage(image, destination);
}

11
StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs

@ -16,6 +16,17 @@ public static class PngDataHelper
private static readonly byte[] Text = { 0x74, 0x45, 0x58, 0x74 };
private static readonly byte[] Iend = { 0x49, 0x45, 0x4E, 0x44 };
public static byte[] AddMetadata(
Stream inputStream,
GenerationParameters generationParameters,
InferenceProjectDocument projectDocument
)
{
using var ms = new MemoryStream();
inputStream.CopyTo(ms);
return AddMetadata(ms.ToArray(), generationParameters, projectDocument);
}
public static byte[] AddMetadata(
byte[] inputImage,
GenerationParameters generationParameters,

18
StabilityMatrix.Avalonia/Languages/Resources.Designer.cs generated

@ -140,6 +140,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Copy.
/// </summary>
public static string Action_Copy {
get {
return ResourceManager.GetString("Action_Copy", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
@ -1526,6 +1535,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Output Sharing.
/// </summary>
public static string Label_UseSharedOutputFolder {
get {
return ResourceManager.GetString("Label_UseSharedOutputFolder", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to VAE.
/// </summary>

6
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -678,7 +678,13 @@
<data name="Action_RestoreDefaultLayout" xml:space="preserve">
<value>Restore Default Layout</value>
</data>
<data name="Label_UseSharedOutputFolder" xml:space="preserve">
<value>Output Sharing</value>
</data>
<data name="Label_BatchIndex" xml:space="preserve">
<value>Batch Index</value>
</data>
<data name="Action_Copy" xml:space="preserve">
<value>Copy</value>
</data>
</root>

73
StabilityMatrix.Avalonia/Models/Inference/FileNameFormat.cs

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace StabilityMatrix.Avalonia.Models.Inference;
public record FileNameFormat
{
public string Template { get; }
public string Prefix { get; set; } = "";
public string Postfix { get; set; } = "";
public IReadOnlyList<FileNameFormatPart> Parts { get; }
private FileNameFormat(string template, IReadOnlyList<FileNameFormatPart> parts)
{
Template = template;
Parts = parts;
}
public FileNameFormat WithBatchPostFix(int current, int total)
{
return this with { Postfix = Postfix + $" ({current}-{total})" };
}
public FileNameFormat WithGridPrefix()
{
return this with { Prefix = Prefix + "Grid_" };
}
public string GetFileName()
{
return Prefix
+ string.Join(
"",
Parts.Select(
part => part.Match(constant => constant, substitution => substitution.Invoke())
)
)
+ Postfix;
}
public static FileNameFormat Parse(string template, FileNameFormatProvider provider)
{
var parts = provider.GetParts(template).ToImmutableArray();
return new FileNameFormat(template, parts);
}
public static bool TryParse(
string template,
FileNameFormatProvider provider,
[NotNullWhen(true)] out FileNameFormat? format
)
{
try
{
format = Parse(template, provider);
return true;
}
catch (ArgumentException)
{
format = null;
return false;
}
}
public const string DefaultTemplate = "{date}_{time}-{model_name}-{seed}";
}

16
StabilityMatrix.Avalonia/Models/Inference/FileNameFormatPart.cs

@ -0,0 +1,16 @@
using System;
using System.Runtime.InteropServices;
using CSharpDiscriminatedUnion.Attributes;
namespace StabilityMatrix.Avalonia.Models.Inference;
[GenerateDiscriminatedUnion(CaseFactoryPrefix = "From")]
[StructLayout(LayoutKind.Auto)]
public readonly partial struct FileNameFormatPart
{
[StructCase("Constant", isDefaultValue: true)]
private readonly string constant;
[StructCase("Substitution")]
private readonly Func<string?> substitution;
}

192
StabilityMatrix.Avalonia/Models/Inference/FileNameFormatProvider.cs

@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using System.Linq;
using System.Text.RegularExpressions;
using Avalonia.Data;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.Models.Inference;
public partial class FileNameFormatProvider
{
public GenerationParameters? GenerationParameters { get; init; }
public InferenceProjectType? ProjectType { get; init; }
public string? ProjectName { get; init; }
private Dictionary<string, Func<string?>>? _substitutions;
public Dictionary<string, Func<string?>> Substitutions =>
_substitutions ??= new Dictionary<string, Func<string?>>
{
{ "seed", () => GenerationParameters?.Seed.ToString() },
{ "prompt", () => GenerationParameters?.PositivePrompt },
{ "negative_prompt", () => GenerationParameters?.NegativePrompt },
{ "model_name", () => GenerationParameters?.ModelName },
{ "model_hash", () => GenerationParameters?.ModelHash },
{ "width", () => GenerationParameters?.Width.ToString() },
{ "height", () => GenerationParameters?.Height.ToString() },
{ "project_type", () => ProjectType?.GetStringValue() },
{ "project_name", () => ProjectName },
{ "date", () => DateTime.Now.ToString("yyyy-MM-dd") },
{ "time", () => DateTime.Now.ToString("HH-mm-ss") }
};
/// <summary>
/// Validate a format string
/// </summary>
/// <param name="format">Format string</param>
/// <exception cref="DataValidationException">Thrown if the format string contains an unknown variable</exception>
[Pure]
public ValidationResult Validate(string format)
{
var regex = BracketRegex();
var matches = regex.Matches(format);
var variables = matches.Select(m => m.Groups[1].Value);
foreach (var variableText in variables)
{
try
{
var (variable, _) = ExtractVariableAndSlice(variableText);
if (!Substitutions.ContainsKey(variable))
{
return new ValidationResult($"Unknown variable '{variable}'");
}
}
catch (Exception e)
{
return new ValidationResult($"Invalid variable '{variableText}': {e.Message}");
}
}
return ValidationResult.Success!;
}
public IEnumerable<FileNameFormatPart> GetParts(string template)
{
var regex = BracketRegex();
var matches = regex.Matches(template);
var parts = new List<FileNameFormatPart>();
// Loop through all parts of the string, including matches and non-matches
var currentIndex = 0;
foreach (var result in matches.Cast<Match>())
{
// If the match is not at the start of the string, add a constant part
if (result.Index != currentIndex)
{
var constant = template[currentIndex..result.Index];
parts.Add(FileNameFormatPart.FromConstant(constant));
currentIndex += constant.Length;
}
// Now we're at start of the current match, add the variable part
var (variable, slice) = ExtractVariableAndSlice(result.Groups[1].Value);
var substitution = Substitutions[variable];
// Slice string if necessary
if (slice is not null)
{
parts.Add(
FileNameFormatPart.FromSubstitution(() =>
{
var value = substitution();
if (value is null)
return null;
if (slice.End is null)
{
value = value[(slice.Start ?? 0)..];
}
else
{
var length =
Math.Min(value.Length, slice.End.Value) - (slice.Start ?? 0);
value = value.Substring(slice.Start ?? 0, length);
}
return value;
})
);
}
else
{
parts.Add(FileNameFormatPart.FromSubstitution(substitution));
}
currentIndex += result.Length;
}
// Add remaining as constant
if (currentIndex != template.Length)
{
var constant = template[currentIndex..];
parts.Add(FileNameFormatPart.FromConstant(constant));
}
return parts;
}
/// <summary>
/// Return a sample provider for UI preview
/// </summary>
public static FileNameFormatProvider GetSample()
{
return new FileNameFormatProvider
{
GenerationParameters = GenerationParameters.GetSample(),
ProjectType = InferenceProjectType.TextToImage,
ProjectName = "Sample Project"
};
}
/// <summary>
/// Extract variable and index from a combined string
/// </summary>
private static (string Variable, Slice? Slice) ExtractVariableAndSlice(string combined)
{
if (IndexRegex().Matches(combined).FirstOrDefault() is not { Success: true } match)
{
return (combined, null);
}
// Variable is everything before the match
var variable = combined[..match.Groups[0].Index];
var start = match.Groups["start"].Value;
var end = match.Groups["end"].Value;
var step = match.Groups["step"].Value;
var slice = new Slice(
string.IsNullOrEmpty(start) ? null : int.Parse(start),
string.IsNullOrEmpty(end) ? null : int.Parse(end),
string.IsNullOrEmpty(step) ? null : int.Parse(step)
);
return (variable, slice);
}
/// <summary>
/// Regex for matching contents within a curly brace.
/// </summary>
[GeneratedRegex(@"\{([a-z_:\d\[\]]+)\}")]
private static partial Regex BracketRegex();
/// <summary>
/// Regex for matching a Python-like array index.
/// </summary>
[GeneratedRegex(@"\[(?:(?<start>-?\d+)?)\:(?:(?<end>-?\d+)?)?(?:\:(?<step>-?\d+))?\]")]
private static partial Regex IndexRegex();
private record Slice(int? Start, int? End, int? Step);
}

8
StabilityMatrix.Avalonia/Models/Inference/FileNameFormatVar.cs

@ -0,0 +1,8 @@
namespace StabilityMatrix.Avalonia.Models.Inference;
public record FileNameFormatVar
{
public required string Variable { get; init; }
public string? Example { get; init; }
}

7
StabilityMatrix.Avalonia/Models/PackageOutputCategory.cs

@ -0,0 +1,7 @@
namespace StabilityMatrix.Avalonia.Models;
public class PackageOutputCategory
{
public required string Name { get; set; }
public required string Path { get; set; }
}

45
StabilityMatrix.Avalonia/Services/InferenceClientManager.cs

@ -1,5 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -345,6 +346,44 @@ public partial class InferenceClientManager : ObservableObject, IInferenceClient
return ConnectAsyncImpl(new Uri("http://127.0.0.1:8188"), cancellationToken);
}
private async Task MigrateLinksIfNeeded(PackagePair packagePair)
{
if (packagePair.InstalledPackage.FullPath is not { } packagePath)
{
throw new ArgumentException("Package path is null", nameof(packagePair));
}
var inferenceDir = settingsManager.ImagesInferenceDirectory;
inferenceDir.Create();
// For locally installed packages only
// Delete ./output/Inference
var legacyInferenceLinkDir = new DirectoryPath(
packagePair.InstalledPackage.FullPath
).JoinDir("output", "Inference");
if (legacyInferenceLinkDir.Exists)
{
logger.LogInformation(
"Deleting legacy inference link at {LegacyDir}",
legacyInferenceLinkDir
);
if (legacyInferenceLinkDir.IsSymbolicLink)
{
await legacyInferenceLinkDir.DeleteAsync(false);
}
else
{
logger.LogWarning(
"Legacy inference link at {LegacyDir} is not a symbolic link, skipping",
legacyInferenceLinkDir
);
}
}
}
/// <inheritdoc />
public async Task ConnectAsync(
PackagePair packagePair,
@ -367,11 +406,7 @@ public partial class InferenceClientManager : ObservableObject, IInferenceClient
logger.LogError(ex, "Error setting up completion provider");
});
// Setup image folder links
await comfyPackage.SetupInferenceOutputFolderLinks(
packagePair.InstalledPackage.FullPath
?? throw new InvalidOperationException("Package does not have a Path")
);
await MigrateLinksIfNeeded(packagePair);
// Get user defined host and port
var host = packagePair.InstalledPackage.GetLaunchArgsHost();

3
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -8,7 +8,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon>
<Version>2.5.3-dev.1</Version>
<Version>2.6.0-dev.1</Version>
<InformationalVersion>$(Version)</InformationalVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
@ -32,6 +32,7 @@
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.2" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="CSharpDiscriminatedUnion" Version="2.0.1" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Dock.Avalonia" Version="11.0.0.2" />
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.2" />

193
StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs

@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using NLog;
@ -27,6 +29,8 @@ using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
using StabilityMatrix.Core.Models.Api.Comfy.WebSocketData;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
@ -41,6 +45,7 @@ public abstract partial class InferenceGenerationViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ISettingsManager settingsManager;
private readonly INotificationService notificationService;
private readonly ServiceManager<ViewModelBase> vmFactory;
@ -60,11 +65,13 @@ public abstract partial class InferenceGenerationViewModelBase
protected InferenceGenerationViewModelBase(
ServiceManager<ViewModelBase> vmFactory,
IInferenceClientManager inferenceClientManager,
INotificationService notificationService
INotificationService notificationService,
ISettingsManager settingsManager
)
: base(notificationService)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.vmFactory = vmFactory;
ClientManager = inferenceClientManager;
@ -75,6 +82,100 @@ public abstract partial class InferenceGenerationViewModelBase
GenerateImageCommand.WithConditionalNotificationErrorHandler(notificationService);
}
/// <summary>
/// Write an image to the default output folder
/// </summary>
protected Task<FilePath> WriteOutputImageAsync(
Stream imageStream,
ImageGenerationEventArgs args,
int batchNum = 0,
int batchTotal = 0,
bool isGrid = false
)
{
var defaultOutputDir = settingsManager.ImagesInferenceDirectory;
defaultOutputDir.Create();
return WriteOutputImageAsync(
imageStream,
defaultOutputDir,
args,
batchNum,
batchTotal,
isGrid
);
}
/// <summary>
/// Write an image to an output folder
/// </summary>
protected async Task<FilePath> WriteOutputImageAsync(
Stream imageStream,
DirectoryPath outputDir,
ImageGenerationEventArgs args,
int batchNum = 0,
int batchTotal = 0,
bool isGrid = false
)
{
var formatTemplateStr = settingsManager.Settings.InferenceOutputImageFileNameFormat;
var formatProvider = new FileNameFormatProvider
{
GenerationParameters = args.Parameters,
ProjectType = args.Project?.ProjectType,
ProjectName = ProjectFile?.NameWithoutExtension
};
// Parse to format
if (
string.IsNullOrEmpty(formatTemplateStr)
|| !FileNameFormat.TryParse(formatTemplateStr, formatProvider, out var format)
)
{
// Fallback to default
Logger.Warn(
"Failed to parse format template: {FormatTemplate}, using default",
formatTemplateStr
);
format = FileNameFormat.Parse(FileNameFormat.DefaultTemplate, formatProvider);
}
if (isGrid)
{
format = format.WithGridPrefix();
}
if (batchNum >= 1 && batchTotal > 1)
{
format = format.WithBatchPostFix(batchNum, batchTotal);
}
var fileName = format.GetFileName() + ".png";
var file = outputDir.JoinFile(fileName);
// Until the file is free, keep adding _{i} to the end
for (var i = 0; i < 100; i++)
{
if (!file.Exists)
break;
file = outputDir.JoinFile($"{fileName}_{i + 1}");
}
// If that fails, append an 7-char uuid
if (file.Exists)
{
file = outputDir.JoinFile($"{fileName}_{Guid.NewGuid():N}"[..7]);
}
await using var fileStream = file.Info.OpenWrite();
await imageStream.CopyToAsync(fileStream);
return file;
}
/// <summary>
/// Builds the image generation prompt
/// </summary>
@ -156,7 +257,7 @@ public abstract partial class InferenceGenerationViewModelBase
// Wait for prompt to finish
await promptTask.Task.WaitAsync(cancellationToken);
Logger.Trace($"Prompt task {promptTask.Id} finished");
Logger.Debug($"Prompt task {promptTask.Id} finished");
// Get output images
var imageOutputs = await client.GetImagesForExecutedPromptAsync(
@ -164,6 +265,20 @@ public abstract partial class InferenceGenerationViewModelBase
cancellationToken
);
if (
!imageOutputs.TryGetValue(args.OutputNodeNames[0], out var images)
|| images is not { Count: > 0 }
)
{
// No images match
notificationService.Show(
"No output",
"Did not receive any output images",
NotificationType.Warning
);
return;
}
// Disable cancellation
await promptInterrupt.DisposeAsync();
@ -172,15 +287,6 @@ public abstract partial class InferenceGenerationViewModelBase
ImageGalleryCardViewModel.ImageSources.Clear();
}
if (
!imageOutputs.TryGetValue(args.OutputNodeNames[0], out var images) || images is null
)
{
// No images match
notificationService.Show("No output", "Did not receive any output images");
return;
}
await ProcessOutputImages(images, args);
}
finally
@ -207,19 +313,22 @@ public abstract partial class InferenceGenerationViewModelBase
ImageGenerationEventArgs args
)
{
var client = args.Client;
// Write metadata to images
var outputImagesBytes = new List<byte[]>();
var outputImages = new List<ImageSource>();
foreach (
var (i, filePath) in images
.Select(image => image.ToFilePath(args.Client.OutputImagesDir!))
.Enumerate()
)
{
if (!filePath.Exists)
foreach (var (i, comfyImage) in images.Enumerate())
{
Logger.Warn($"Image file {filePath} does not exist");
continue;
}
Logger.Debug("Downloading image: {FileName}", comfyImage.FileName);
var imageStream = await client.GetImageStreamAsync(comfyImage);
using var ms = new MemoryStream();
await imageStream.CopyToAsync(ms);
var imageArray = ms.ToArray();
outputImagesBytes.Add(imageArray);
var parameters = args.Parameters!;
var project = args.Project!;
@ -248,17 +357,15 @@ public abstract partial class InferenceGenerationViewModelBase
);
}
var bytesWithMetadata = PngDataHelper.AddMetadata(
await filePath.ReadAllBytesAsync(),
parameters,
project
);
var bytesWithMetadata = PngDataHelper.AddMetadata(imageArray, parameters, project);
await using (var outputStream = filePath.Info.OpenWrite())
{
await outputStream.WriteAsync(bytesWithMetadata);
await outputStream.FlushAsync();
}
// Write using generated name
var filePath = await WriteOutputImageAsync(
new MemoryStream(bytesWithMetadata),
args,
i + 1,
images.Count
);
outputImages.Add(new ImageSource(filePath));
@ -268,17 +375,7 @@ public abstract partial class InferenceGenerationViewModelBase
// Download all images to make grid, if multiple
if (outputImages.Count > 1)
{
var outputDir = outputImages[0].LocalFile!.Directory;
var loadedImages = outputImages
.Select(i => i.LocalFile)
.Where(f => f is { Exists: true })
.Select(f =>
{
using var stream = f!.Info.OpenRead();
return SKImage.FromEncodedData(stream);
})
.ToImmutableArray();
var loadedImages = outputImagesBytes.Select(SKImage.FromEncodedData).ToImmutableArray();
var project = args.Project!;
@ -297,13 +394,11 @@ public abstract partial class InferenceGenerationViewModelBase
);
// Save to disk
var lastName = outputImages.Last().LocalFile?.Info.Name;
var gridPath = outputDir!.JoinFile($"grid-{lastName}");
await using (var fileStream = gridPath.Info.OpenWrite())
{
await fileStream.WriteAsync(gridBytesWithMetadata);
}
var gridPath = await WriteOutputImageAsync(
new MemoryStream(gridBytesWithMetadata),
args,
isGrid: true
);
// Insert to start of images
var gridImage = new ImageSource(gridPath);

1
StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs

@ -9,6 +9,7 @@ using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;

4
StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs

@ -19,6 +19,7 @@ using StabilityMatrix.Avalonia.Views.Inference;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
using StabilityMatrix.Core.Services;
using Path = System.IO.Path;
#pragma warning disable CS0657 // Not a valid attribute location for this declaration
@ -60,9 +61,10 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase
public InferenceImageUpscaleViewModel(
INotificationService notificationService,
IInferenceClientManager inferenceClientManager,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> vmFactory
)
: base(vmFactory, inferenceClientManager, notificationService)
: base(vmFactory, inferenceClientManager, notificationService, settingsManager)
{
this.notificationService = notificationService;

3
StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs

@ -86,10 +86,11 @@ public class InferenceTextToImageViewModel
public InferenceTextToImageViewModel(
INotificationService notificationService,
IInferenceClientManager inferenceClientManager,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> vmFactory,
IModelIndexService modelIndexService
)
: base(vmFactory, inferenceClientManager, notificationService)
: base(vmFactory, inferenceClientManager, notificationService, settingsManager)
{
this.notificationService = notificationService;
this.modelIndexService = modelIndexService;

78
StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs

@ -111,6 +111,10 @@ public partial class InferenceViewModel : PageViewModelBase
// Keep RunningPackage updated with the current package pair
EventManager.Instance.RunningPackageStatusChanged += OnRunningPackageStatusChanged;
// "Send to Inference"
EventManager.Instance.InferenceTextToImageRequested += OnInferenceTextToImageRequested;
EventManager.Instance.InferenceUpscaleRequested += OnInferenceUpscaleRequested;
MenuSaveAsCommand.WithConditionalNotificationErrorHandler(notificationService);
MenuOpenProjectCommand.WithConditionalNotificationErrorHandler(notificationService);
}
@ -232,6 +236,16 @@ public partial class InferenceViewModel : PageViewModelBase
}
}
private void OnInferenceTextToImageRequested(object? sender, LocalImageFile e)
{
Dispatcher.UIThread.Post(() => AddTabFromImage(e).SafeFireAndForget());
}
private void OnInferenceUpscaleRequested(object? sender, LocalImageFile e)
{
Dispatcher.UIThread.Post(() => AddUpscalerTabFromImage(e).SafeFireAndForget());
}
/// <summary>
/// Update the database with current tabs
/// </summary>
@ -593,6 +607,70 @@ public partial class InferenceViewModel : PageViewModelBase
await SyncTabStatesWithDatabase();
}
private async Task AddTabFromImage(LocalImageFile imageFile)
{
var metadata = imageFile.ReadMetadata();
InferenceTabViewModelBase? vm = null;
if (!string.IsNullOrWhiteSpace(metadata.SMProject))
{
var document = JsonSerializer.Deserialize<InferenceProjectDocument>(metadata.SMProject);
if (document is null)
{
throw new ApplicationException(
"MenuOpenProject: Deserialize project file returned null"
);
}
if (document.State is null)
{
throw new ApplicationException("Project file does not have 'State' key");
}
document.VerifyVersion();
var textToImage = vmFactory.Get<InferenceTextToImageViewModel>();
textToImage.LoadStateFromJsonObject(document.State);
vm = textToImage;
}
else if (!string.IsNullOrWhiteSpace(metadata.Parameters))
{
if (GenerationParameters.TryParse(metadata.Parameters, out var generationParameters))
{
var textToImageViewModel = vmFactory.Get<InferenceTextToImageViewModel>();
textToImageViewModel.LoadStateFromParameters(generationParameters);
vm = textToImageViewModel;
}
}
if (vm == null)
{
notificationService.Show(
"Unable to load project from image",
"No image metadata found",
NotificationType.Error
);
return;
}
Tabs.Add(vm);
SelectedTab = vm;
await SyncTabStatesWithDatabase();
}
private async Task AddUpscalerTabFromImage(LocalImageFile imageFile)
{
var upscaleVm = vmFactory.Get<InferenceImageUpscaleViewModel>();
upscaleVm.IsUpscaleEnabled = true;
upscaleVm.SelectImageCardViewModel.ImageSource = new ImageSource(imageFile.AbsolutePath);
Tabs.Add(upscaleVm);
SelectedTab = upscaleVm;
await SyncTabStatesWithDatabase();
}
/// <summary>
/// Menu "Open Project" command.
/// </summary>

265
StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs

@ -0,0 +1,265 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using AsyncImageLoader;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DynamicData;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(OutputsPage))]
public partial class OutputsPageViewModel : PageViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly INotificationService notificationService;
private readonly INavigationService navigationService;
public override string Title => "Outputs";
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Grid, IsFilled = true };
public SourceCache<LocalImageFile, string> OutputsCache { get; } = new(p => p.AbsolutePath);
public IObservableCollection<LocalImageFile> Outputs { get; } =
new ObservableCollectionExtended<LocalImageFile>();
public IEnumerable<SharedOutputType> OutputTypes { get; } = Enum.GetValues<SharedOutputType>();
[ObservableProperty]
private ObservableCollection<PackageOutputCategory> categories;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowOutputTypes))]
private PackageOutputCategory selectedCategory;
[ObservableProperty]
private SharedOutputType selectedOutputType;
public bool CanShowOutputTypes => SelectedCategory.Name.Equals("Shared Output Folder");
public OutputsPageViewModel(
ISettingsManager settingsManager,
IPackageFactory packageFactory,
INotificationService notificationService,
INavigationService navigationService
)
{
this.settingsManager = settingsManager;
this.notificationService = notificationService;
this.navigationService = navigationService;
OutputsCache
.Connect()
.DeferUntilLoaded()
.SortBy(x => x.CreatedAt, SortDirection.Descending)
.Bind(Outputs)
.Subscribe();
if (!settingsManager.IsLibraryDirSet || Design.IsDesignMode)
return;
var packageCategories = settingsManager.Settings.InstalledPackages
.Where(x => !x.UseSharedOutputFolder)
.Select(p =>
{
var basePackage = packageFactory[p.PackageName!];
if (basePackage is null)
return null;
return new PackageOutputCategory
{
Path = Path.Combine(p.FullPath, basePackage.OutputFolderName),
Name = p.DisplayName
};
})
.ToList();
packageCategories.Insert(
0,
new PackageOutputCategory
{
Path = settingsManager.ImagesDirectory,
Name = "Shared Output Folder"
}
);
Categories = new ObservableCollection<PackageOutputCategory>(packageCategories);
SelectedCategory = Categories.First();
SelectedOutputType = SharedOutputType.All;
}
public override void OnLoaded()
{
if (Design.IsDesignMode)
return;
var path =
CanShowOutputTypes && SelectedOutputType != SharedOutputType.All
? Path.Combine(SelectedCategory.Path, SelectedOutputType.ToString())
: SelectedCategory.Path;
GetOutputs(path);
}
partial void OnSelectedCategoryChanged(
PackageOutputCategory? oldValue,
PackageOutputCategory? newValue
)
{
if (oldValue == newValue || newValue == null)
return;
var path =
CanShowOutputTypes && SelectedOutputType != SharedOutputType.All
? Path.Combine(newValue.Path, SelectedOutputType.ToString())
: SelectedCategory.Path;
GetOutputs(path);
}
partial void OnSelectedOutputTypeChanged(SharedOutputType oldValue, SharedOutputType newValue)
{
if (oldValue == newValue)
return;
var path =
newValue == SharedOutputType.All
? SelectedCategory.Path
: Path.Combine(SelectedCategory.Path, newValue.ToString());
GetOutputs(path);
}
public async Task OnImageClick(LocalImageFile item)
{
var currentIndex = Outputs.IndexOf(item);
var image = new ImageSource(new FilePath(item.AbsolutePath));
// Preload
await image.GetBitmapAsync();
var vm = new ImageViewerViewModel { ImageSource = image, LocalImageFile = item };
using var onNext = Observable
.FromEventPattern<DirectionalNavigationEventArgs>(
vm,
nameof(ImageViewerViewModel.NavigationRequested)
)
.Subscribe(ctx =>
{
Dispatcher.UIThread
.InvokeAsync(async () =>
{
var sender = (ImageViewerViewModel)ctx.Sender!;
var newIndex = currentIndex + (ctx.EventArgs.IsNext ? 1 : -1);
if (newIndex >= 0 && newIndex < Outputs.Count)
{
var newImage = Outputs[newIndex];
var newImageSource = new ImageSource(
new FilePath(newImage.AbsolutePath)
);
// Preload
await newImageSource.GetBitmapAsync();
sender.ImageSource = newImageSource;
sender.LocalImageFile = newImage;
currentIndex = newIndex;
}
})
.SafeFireAndForget();
});
await vm.GetDialog().ShowAsync();
}
public async Task CopyImage(string imagePath)
{
var clipboard = App.Clipboard;
await clipboard.SetFileDataObjectAsync(imagePath);
}
public async Task OpenImage(string imagePath) => await ProcessRunner.OpenFileBrowser(imagePath);
public async Task DeleteImage(LocalImageFile? item)
{
if (item?.GetFullPath(settingsManager.ImagesDirectory) is not { } imagePath)
{
return;
}
// Delete the file
var imageFile = new FilePath(imagePath);
var result = await notificationService.TryAsync(imageFile.DeleteAsync());
if (!result.IsSuccessful)
{
return;
}
Outputs.Remove(item);
// Invalidate cache
if (ImageLoader.AsyncImageLoader is FallbackRamCachedWebImageLoader loader)
{
loader.RemoveAllNamesFromCache(imageFile.Name);
}
}
public void SendToTextToImage(LocalImageFile image)
{
navigationService.NavigateTo<InferenceViewModel>();
EventManager.Instance.OnInferenceTextToImageRequested(image);
}
public void SendToUpscale(LocalImageFile image)
{
navigationService.NavigateTo<InferenceViewModel>();
EventManager.Instance.OnInferenceUpscaleRequested(image);
}
private void GetOutputs(string directory)
{
if (!settingsManager.IsLibraryDirSet)
return;
if (!Directory.Exists(directory) && SelectedOutputType != SharedOutputType.All)
{
Directory.CreateDirectory(directory);
return;
}
var list = Directory
.EnumerateFiles(directory, "*.png", SearchOption.AllDirectories)
.Select(file => LocalImageFile.FromPath(file))
.OrderByDescending(f => f.CreatedAt);
OutputsCache.EditDiff(list, (x, y) => x.AbsolutePath == y.AbsolutePath);
}
}

31
StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs

@ -62,6 +62,9 @@ public partial class PackageCardViewModel : ProgressViewModel
[ObservableProperty]
private bool canUseConfigMethod;
[ObservableProperty]
private bool useSharedOutput;
public PackageCardViewModel(
ILogger<PackageCardViewModel> logger,
IPackageFactory packageFactory,
@ -100,6 +103,7 @@ public partial class PackageCardViewModel : ProgressViewModel
CanUseConfigMethod =
basePackage?.AvailableSharedFolderMethods.Contains(SharedFolderMethod.Configuration)
?? false;
UseSharedOutput = Package?.UseSharedOutputFolder ?? false;
}
}
@ -367,6 +371,33 @@ public partial class PackageCardViewModel : ProgressViewModel
public void ToggleSharedModelNone() => IsSharedModelDisabled = !IsSharedModelDisabled;
public void ToggleSharedOutput() => UseSharedOutput = !UseSharedOutput;
partial void OnUseSharedOutputChanged(bool value)
{
if (Package == null)
return;
if (value == Package.UseSharedOutputFolder)
return;
using var st = settingsManager.BeginTransaction();
Package.UseSharedOutputFolder = value;
var basePackage = packageFactory[Package.PackageName!];
if (basePackage == null)
return;
if (value)
{
basePackage.SetupOutputFolderLinks(Package.FullPath!);
}
else
{
basePackage.RemoveOutputFolderLinks(Package.FullPath!);
}
}
// fake radio button stuff
partial void OnIsSharedModelSymlinkChanged(bool oldValue, bool newValue)
{

64
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -3,10 +3,12 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
@ -21,6 +23,7 @@ using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using NLog;
using SkiaSharp;
@ -29,6 +32,7 @@ using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Models.TagCompletion;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
@ -107,6 +111,25 @@ public partial class SettingsViewModel : PageViewModelBase
[ObservableProperty]
private bool isCompletionRemoveUnderscoresEnabled = true;
[ObservableProperty]
[CustomValidation(typeof(SettingsViewModel), nameof(ValidateOutputImageFileNameFormat))]
private string? outputImageFileNameFormat;
[ObservableProperty]
private string? outputImageFileNameFormatSample;
public IEnumerable<FileNameFormatVar> OutputImageFileNameFormatVars =>
FileNameFormatProvider
.GetSample()
.Substitutions.Select(
kv =>
new FileNameFormatVar
{
Variable = $"{{{kv.Key}}}",
Example = kv.Value.Invoke()
}
);
[ObservableProperty]
private bool isImageViewerPixelGridEnabled = true;
@ -201,6 +224,39 @@ public partial class SettingsViewModel : PageViewModelBase
true
);
this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat)
.Throttle(TimeSpan.FromMilliseconds(50))
.Subscribe(formatProperty =>
{
var provider = FileNameFormatProvider.GetSample();
var template = formatProperty.Value;
if (
!string.IsNullOrEmpty(template)
&& provider.Validate(template) == ValidationResult.Success
)
{
var format = FileNameFormat.Parse(template, provider);
OutputImageFileNameFormatSample = format.GetFileName() + ".png";
}
else
{
// Use default format if empty
var defaultFormat = FileNameFormat.Parse(
FileNameFormat.DefaultTemplate,
provider
);
OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png";
}
});
settingsManager.RelayPropertyFor(
this,
vm => vm.OutputImageFileNameFormat,
settings => settings.InferenceOutputImageFileNameFormat,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsImageViewerPixelGridEnabled,
@ -225,6 +281,14 @@ public partial class SettingsViewModel : PageViewModelBase
UpdateAvailableTagCompletionCsvs();
}
public static ValidationResult ValidateOutputImageFileNameFormat(
string format,
ValidationContext context
)
{
return FileNameFormatProvider.GetSample().Validate(format);
}
partial void OnSelectedThemeChanged(string? value)
{
// In case design / tests

14
StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml

@ -106,6 +106,20 @@
Grid.Row="1"
Text="{Binding NegativePrompt}" />
</Grid>
<Grid RowDefinitions="Auto,*">
<TextBlock Text="Model" Theme="{DynamicResource BodyStrongTextBlockStyle}" />
<SelectableTextBlock
Grid.Row="1"
Text="{Binding ModelName}" />
</Grid>
<Grid RowDefinitions="Auto,*">
<TextBlock Text="Seed" Theme="{DynamicResource BodyStrongTextBlockStyle}" />
<SelectableTextBlock
Grid.Row="1"
Text="{Binding Seed}" />
</Grid>
</StackPanel>
</ui:TeachingTip>

145
StabilityMatrix.Avalonia/Views/OutputsPage.axaml

@ -0,0 +1,145 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.OutputsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:models1="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:database="clr-namespace:StabilityMatrix.Core.Models.Database;assembly=StabilityMatrix.Core"
xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core"
d:DataContext="{x:Static mocks:DesignData.OutputsPageViewModel}"
d:DesignHeight="450"
d:DesignWidth="700"
x:CompileBindings="True"
x:DataType="vm:OutputsPageViewModel"
mc:Ignorable="d">
<Grid RowDefinitions="Auto, *" Margin="16">
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,16"
HorizontalAlignment="Left">
<Grid RowDefinitions="Auto, *">
<TextBlock Text="Output Folder" Margin="4"/>
<ComboBox Grid.Row="1" ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
MinWidth="150">
<ComboBox.Styles>
<Style
Selector="ComboBox /template/ ContentControl#ContentPresenter &gt; StackPanel &gt; TextBlock:nth-child(2)">
<Setter Property="IsVisible" Value="False" />
</Style>
</ComboBox.Styles>
<ComboBox.ItemTemplate>
<DataTemplate DataType="{x:Type models1:PackageOutputCategory}">
<StackPanel>
<TextBlock
Margin="0,4,0,4"
Text="{Binding Name, Mode=OneWay}" />
<TextBlock Text="{Binding Path, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<Grid RowDefinitions="Auto, *" Margin="8,0"
IsVisible="{Binding CanShowOutputTypes}">
<TextBlock Text="Output Type" Margin="4"/>
<ComboBox Grid.Row="1" ItemsSource="{Binding OutputTypes}"
SelectedItem="{Binding SelectedOutputType}"
MinWidth="150"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"/>
</Grid>
</StackPanel>
<ScrollViewer Grid.Row="1">
<ItemsRepeater
ItemsSource="{Binding Outputs}"
VerticalAlignment="Top">
<ItemsRepeater.Layout>
<UniformGridLayout MinColumnSpacing="16" MinRowSpacing="16" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type database:LocalImageFile}">
<Button
Margin="0"
Padding="4"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).OnImageClick}"
CommandParameter="{Binding }">
<controls:BetterAdvancedImage
Width="300"
Height="300"
Stretch="UniformToFill"
Source="{Binding AbsolutePath}" />
<Button.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_Delete}"
IconSource="Delete"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).DeleteImage}"
CommandParameter="{Binding }" />
<ui:MenuFlyoutItem
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_Copy}"
IconSource="Copy"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).CopyImage}"
CommandParameter="{Binding AbsolutePath}" />
<ui:MenuFlyoutItem
HotKey="{x:Null}"
Text="{x:Static lang:Resources.Action_OpenInExplorer}"
IconSource="Folder"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).OpenImage}"
CommandParameter="{Binding AbsolutePath}" />
<ui:MenuFlyoutSeparator
IsVisible="{Binding GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ui:MenuFlyoutSubItem Text="Send to Inference" IconSource="Share"
IsVisible="{Binding GenerationParameters, Converter={x:Static ObjectConverters.IsNotNull}}">
<ui:MenuFlyoutItem
HotKey="{x:Null}"
Text="Text to Image"
IconSource="FullScreenMaximize"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).SendToTextToImage}"
CommandParameter="{Binding }" />
<ui:MenuFlyoutItem
HotKey="{x:Null}"
Text="Image to Image"
IsEnabled="False"
IconSource="ImageCopy"
CommandParameter="{Binding }" />
<ui:MenuFlyoutItem
Text="Inpainting"
IconSource="ImageEdit"
IsEnabled="False"
HotKey="{x:Null}"
CommandParameter="{Binding }" />
<ui:MenuFlyoutItem
Text="Upscale"
HotKey="{x:Null}"
Command="{Binding $parent[ItemsRepeater].((vm:OutputsPageViewModel)DataContext).SendToUpscale}"
CommandParameter="{Binding }">
<ui:MenuFlyoutItem.IconSource>
<fluentAvalonia:SymbolIconSource
FontSize="10"
Symbol="ResizeImage" />
</ui:MenuFlyoutItem.IconSource>
</ui:MenuFlyoutItem>
</ui:MenuFlyoutSubItem>
</ui:FAMenuFlyout>
</Button.ContextFlyout>
</Button>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ScrollViewer>
</Grid>
</controls:UserControlBase>

14
StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs

@ -0,0 +1,14 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Views;
public partial class OutputsPage : UserControlBase
{
public OutputsPage()
{
InitializeComponent();
}
}

10
StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml

@ -97,6 +97,16 @@
</MenuItem>
<!-- ReSharper enable Xaml.RedundantResource -->
</MenuItem>
<MenuItem
Header="{x:Static lang:Resources.Label_UseSharedOutputFolder}"
Command="{Binding ToggleSharedOutput}">
<MenuItem.Icon>
<CheckBox Margin="8,0,0,0"
Padding="0"
Width="28" Height="28"
IsChecked="{Binding UseSharedOutput}"/>
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="{x:Static lang:Resources.Action_Uninstall}"
Command="{Binding Uninstall}">

46
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -6,10 +6,15 @@
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.Models.Inference"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
Focusable="True"
d:DataContext="{x:Static mocks:DesignData.SettingsViewModel}"
d:DesignHeight="700"
d:DesignWidth="800"
@ -83,10 +88,10 @@
</Grid>
<!-- Inference UI -->
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*">
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*,*">
<TextBlock
FontWeight="Medium"
Text="Inference UI"
Text="Inference"
Margin="0,0,0,8" />
<!-- Auto Completion -->
<ui:SettingsExpander Grid.Row="1"
@ -155,6 +160,43 @@
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Output Image Files -->
<ui:SettingsExpander Grid.Row="3"
Header="Output Image Files"
Margin="8,0,8,4">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="TabDesktopImage"/>
</ui:SettingsExpander.IconSource>
<!-- File name pattern -->
<ui:SettingsExpanderItem
Content="File name pattern"
Description="{Binding OutputImageFileNameFormatSample}"
IconSource="Rename">
<ui:SettingsExpanderItem.Footer>
<TextBox
Name="OutputImageFileNameFormatTextBox"
Watermark="{x:Static inference:FileNameFormat.DefaultTemplate}"
FontSize="13"
MinWidth="150"
Text="{Binding OutputImageFileNameFormat}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:TeachingTip
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}"
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}"
PreferredPlacement="Top"
Title="Format Variables"
Grid.Row="3">
<DataGrid
AutoGenerateColumns="True"
ItemsSource="{Binding OutputImageFileNameFormatVars}" />
<!--<mdxaml:MarkdownScrollViewer
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>-->
</ui:TeachingTip>
</Grid>
<!-- Environment Options -->

9
StabilityMatrix.Core/Helper/EventManager.cs

@ -1,5 +1,6 @@
using System.Globalization;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Progress;
@ -34,6 +35,8 @@ public class EventManager
public event EventHandler? ModelIndexChanged;
public event EventHandler<FilePath>? ImageFileAdded;
public event EventHandler<LocalImageFile>? InferenceTextToImageRequested;
public event EventHandler<LocalImageFile>? InferenceUpscaleRequested;
public void OnGlobalProgressChanged(int progress) =>
GlobalProgressChanged?.Invoke(this, progress);
@ -74,4 +77,10 @@ public class EventManager
public void OnModelIndexChanged() => ModelIndexChanged?.Invoke(this, EventArgs.Empty);
public void OnImageFileAdded(FilePath filePath) => ImageFileAdded?.Invoke(this, filePath);
public void OnInferenceTextToImageRequested(LocalImageFile imageFile) =>
InferenceTextToImageRequested?.Invoke(this, imageFile);
public void OnInferenceUpscaleRequested(LocalImageFile imageFile) =>
InferenceUpscaleRequested?.Invoke(this, imageFile);
}

29
StabilityMatrix.Core/Helper/SharedFolders.cs

@ -45,10 +45,12 @@ public class SharedFolders : ISharedFolders
/// <param name="sourceDir">Shared source (i.e. "Models/")</param>
/// <param name="destinationDir">Destination (i.e. "webui/models/lora")</param>
/// <param name="overwrite">Whether to overwrite the destination if it exists</param>
/// <param name="recursiveDelete">Whether to recursively delete the directory after moving data out of it</param>
public static async Task CreateOrUpdateLink(
DirectoryPath sourceDir,
DirectoryPath destinationDir,
bool overwrite = false
bool overwrite = false,
bool recursiveDelete = false
)
{
// Create source folder if it doesn't exist
@ -74,6 +76,7 @@ public class SharedFolders : ISharedFolders
// Otherwise delete the link
Logger.Info($"Deleting existing junction at target {destinationDir}");
destinationDir.Info.Attributes = FileAttributes.Normal;
await destinationDir.DeleteAsync(false).ConfigureAwait(false);
}
else
@ -93,7 +96,7 @@ public class SharedFolders : ISharedFolders
}
Logger.Info($"Deleting existing empty folder at target {destinationDir}");
await destinationDir.DeleteAsync(false).ConfigureAwait(false);
await destinationDir.DeleteAsync(recursiveDelete).ConfigureAwait(false);
}
}
@ -117,11 +120,13 @@ public class SharedFolders : ISharedFolders
/// Updates or creates shared links for a package.
/// Will attempt to move files from the destination to the source if the destination is not empty.
/// </summary>
public static async Task UpdateLinksForPackage(
Dictionary<SharedFolderType, IReadOnlyList<string>> sharedFolders,
public static async Task UpdateLinksForPackage<T>(
Dictionary<T, IReadOnlyList<string>> sharedFolders,
DirectoryPath modelsDirectory,
DirectoryPath installDirectory
DirectoryPath installDirectory,
bool recursiveDelete = false
)
where T : Enum
{
foreach (var (folderType, relativePaths) in sharedFolders)
{
@ -130,14 +135,22 @@ public class SharedFolders : ISharedFolders
var sourceDir = new DirectoryPath(modelsDirectory, folderType.GetStringValue());
var destinationDir = installDirectory.JoinDir(relativePath);
await CreateOrUpdateLink(sourceDir, destinationDir).ConfigureAwait(false);
await CreateOrUpdateLink(
sourceDir,
destinationDir,
recursiveDelete: recursiveDelete
)
.ConfigureAwait(false);
}
}
}
public static void RemoveLinksForPackage(BasePackage package, DirectoryPath installPath)
public static void RemoveLinksForPackage<T>(
Dictionary<T, IReadOnlyList<string>>? sharedFolders,
DirectoryPath installPath
)
where T : Enum
{
var sharedFolders = package.SharedFolders;
if (sharedFolders == null)
{
return;

5
StabilityMatrix.Core/Models/Database/LocalImageFile.cs

@ -17,6 +17,8 @@ public class LocalImageFile
[BsonId]
public required string RelativePath { get; set; }
public required string AbsolutePath { get; set; }
/// <summary>
/// Type of the model file.
/// </summary>
@ -124,9 +126,12 @@ public class LocalImageFile
GenerationParameters.TryParse(metadata, out genParams);
}
filePath.Info.Refresh();
return new LocalImageFile
{
RelativePath = relativePath,
AbsolutePath = filePath,
ImageType = imageType,
CreatedAt = filePath.Info.CreationTimeUtc,
LastModifiedAt = filePath.Info.LastWriteTimeUtc,

16
StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs

@ -161,7 +161,21 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary>
public async Task<FilePath> MoveToAsync(FilePath destinationFile)
{
await Task.Run(() => Info.MoveTo(destinationFile.FullPath)).ConfigureAwait(false);
await Task.Run(() =>
{
var path = destinationFile.FullPath;
if (destinationFile.Exists)
{
var num = Random.Shared.NextInt64(0, 10000);
path = path.Replace(
destinationFile.NameWithoutExtension,
$"{destinationFile.NameWithoutExtension}_{num}"
);
}
Info.MoveTo(path);
})
.ConfigureAwait(false);
// Return the new path
return destinationFile;
}

20
StabilityMatrix.Core/Models/GenerationParameters.cs

@ -126,6 +126,26 @@ public partial record GenerationParameters
return (sampler, scheduler);
}
/// <summary>
/// Return a sample parameters for UI preview
/// </summary>
public static GenerationParameters GetSample()
{
return new GenerationParameters
{
PositivePrompt = "(cat:1.2), by artist, detailed, [shaded]",
NegativePrompt = "blurry, jpg artifacts",
Steps = 30,
CfgScale = 7,
Width = 640,
Height = 896,
Seed = 124825529,
ModelName = "ExampleMix7",
ModelHash = "b899d188a1ac7356bfb9399b2277d5b21712aa360f8f9514fba6fcce021baff7",
Sampler = "DPM++ 2M Karras"
};
}
// Example: Steps: 30, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 2216407431, Size: 640x896, Model hash: eb2h052f91, Model: anime_v1
[GeneratedRegex(
"""^Steps: (?<Steps>\d+), Sampler: (?<Sampler>.+?), CFG scale: (?<CfgScale>\d+(\.\d+)?), Seed: (?<Seed>\d+), Size: (?<Width>\d+)x(?<Height>\d+), Model hash: (?<ModelHash>.+?), Model: (?<ModelName>.+)$"""

1
StabilityMatrix.Core/Models/InstalledPackage.cs

@ -50,6 +50,7 @@ public class InstalledPackage : IJsonOnDeserialized
public bool UpdateAvailable { get; set; }
public TorchVersion? PreferredTorchVersion { get; set; }
public SharedFolderMethod? PreferredSharedFolderMethod { get; set; }
public bool UseSharedOutputFolder { get; set; }
/// <summary>
/// Get the launch args host option value.

13
StabilityMatrix.Core/Models/Packages/A3WebUI.cs

@ -61,6 +61,17 @@ public class A3WebUI : BaseGitPackage
[SharedFolderType.AfterDetailer] = new[] { "models/adetailer" }
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new()
{
[SharedOutputType.Extras] = new[] { "outputs/extras-images" },
[SharedOutputType.Saved] = new[] { "log/images" },
[SharedOutputType.Img2Img] = new[] { "outputs/img2img-images" },
[SharedOutputType.Text2Img] = new[] { "outputs/text2img-images" },
[SharedOutputType.Img2ImgGrids] = new[] { "outputs/img2img-grids" },
[SharedOutputType.Text2ImgGrids] = new[] { "outputs/text2img-grids" }
};
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
public override List<LaunchOptionDefinition> LaunchOptions =>
new()
@ -165,6 +176,8 @@ public class A3WebUI : BaseGitPackage
return release.TagName!;
}
public override string OutputFolderName => "outputs";
public override async Task InstallPackage(
string installLocation,
TorchVersion torchVersion,

32
StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs

@ -372,7 +372,37 @@ public abstract class BaseGitPackage : BasePackage
{
if (SharedFolders is not null && sharedFolderMethod == SharedFolderMethod.Symlink)
{
StabilityMatrix.Core.Helper.SharedFolders.RemoveLinksForPackage(this, installDirectory);
StabilityMatrix.Core.Helper.SharedFolders.RemoveLinksForPackage(
SharedFolders,
installDirectory
);
}
return Task.CompletedTask;
}
public override Task SetupOutputFolderLinks(DirectoryPath installDirectory)
{
if (SharedOutputFolders is { } sharedOutputFolders)
{
return StabilityMatrix.Core.Helper.SharedFolders.UpdateLinksForPackage(
sharedOutputFolders,
SettingsManager.ImagesDirectory,
installDirectory,
recursiveDelete: true
);
}
return Task.CompletedTask;
}
public override Task RemoveOutputFolderLinks(DirectoryPath installDirectory)
{
if (SharedOutputFolders is { } sharedOutputFolders)
{
StabilityMatrix.Core.Helper.SharedFolders.RemoveLinksForPackage(
sharedOutputFolders,
installDirectory
);
}
return Task.CompletedTask;
}

11
StabilityMatrix.Core/Models/Packages/BasePackage.cs

@ -38,6 +38,8 @@ public abstract class BasePackage
public virtual bool IsInferenceCompatible => false;
public abstract string OutputFolderName { get; }
public abstract Task DownloadPackage(
string installLocation,
DownloadPackageVersionOptions versionOptions,
@ -93,6 +95,9 @@ public abstract class BasePackage
SharedFolderMethod sharedFolderMethod
);
public abstract Task SetupOutputFolderLinks(DirectoryPath installDirectory);
public abstract Task RemoveOutputFolderLinks(DirectoryPath installDirectory);
public abstract IEnumerable<TorchVersion> AvailableTorchVersions { get; }
public virtual TorchVersion GetRecommendedTorchVersion()
@ -142,7 +147,11 @@ public abstract class BasePackage
/// The shared folders that this package supports.
/// Mapping of <see cref="SharedFolderType"/> to the relative paths from the package root.
/// </summary>
public virtual Dictionary<SharedFolderType, IReadOnlyList<string>>? SharedFolders { get; }
public abstract Dictionary<SharedFolderType, IReadOnlyList<string>>? SharedFolders { get; }
public abstract Dictionary<
SharedOutputType,
IReadOnlyList<string>
>? SharedOutputFolders { get; }
public abstract Task<string> GetLatestVersion();
public abstract Task<PackageVersionOptions> GetAllVersionOptions();

5
StabilityMatrix.Core/Models/Packages/ComfyUI.cs

@ -30,6 +30,8 @@ public class ComfyUI : BaseGitPackage
new("https://github.com/comfyanonymous/ComfyUI/raw/master/comfyui_screenshot.png");
public override bool ShouldIgnoreReleases => true;
public override bool IsInferenceCompatible => true;
public override string OutputFolderName => "output";
public override SharedFolderMethod RecommendedSharedFolderMethod =>
SharedFolderMethod.Configuration;
@ -58,6 +60,9 @@ public class ComfyUI : BaseGitPackage
[SharedFolderType.Hypernetwork] = new[] { "models/hypernetworks" },
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new() { [SharedOutputType.Text2Img] = new[] { "output" } };
public override List<LaunchOptionDefinition> LaunchOptions =>
new List<LaunchOptionDefinition>
{

8
StabilityMatrix.Core/Models/Packages/DankDiffusion.cs

@ -30,6 +30,8 @@ public class DankDiffusion : BaseGitPackage
public override Uri PreviewImageUri { get; }
public override string OutputFolderName { get; }
public override Task RunPackage(
string installedPackagePath,
string command,
@ -68,6 +70,12 @@ public class DankDiffusion : BaseGitPackage
public override List<LaunchOptionDefinition> LaunchOptions { get; }
public override Dictionary<SharedFolderType, IReadOnlyList<string>>? SharedFolders { get; }
public override Dictionary<
SharedOutputType,
IReadOnlyList<string>
>? SharedOutputFolders { get; }
public override Task<string> GetLatestVersion()
{
throw new NotImplementedException();

5
StabilityMatrix.Core/Models/Packages/Fooocus.cs

@ -83,6 +83,9 @@ public class Fooocus : BaseGitPackage
[SharedFolderType.Hypernetwork] = new[] { "models/hypernetworks" }
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new() { [SharedOutputType.Text2Img] = new[] { "outputs" } };
public override IEnumerable<TorchVersion> AvailableTorchVersions =>
new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.Rocm };
@ -90,6 +93,8 @@ public class Fooocus : BaseGitPackage
public override bool ShouldIgnoreReleases => true;
public override string OutputFolderName => "outputs";
public override async Task InstallPackage(
string installLocation,
TorchVersion torchVersion,

5
StabilityMatrix.Core/Models/Packages/FooocusMre.cs

@ -85,6 +85,9 @@ public class FooocusMre : BaseGitPackage
[SharedFolderType.Hypernetwork] = new[] { "models/hypernetworks" }
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new() { [SharedOutputType.Text2Img] = new[] { "outputs" } };
public override IEnumerable<TorchVersion> AvailableTorchVersions =>
new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.Rocm };
@ -94,6 +97,8 @@ public class FooocusMre : BaseGitPackage
return release.TagName!;
}
public override string OutputFolderName => "outputs";
public override async Task InstallPackage(
string installLocation,
TorchVersion torchVersion,

7
StabilityMatrix.Core/Models/Packages/InvokeAI.cs

@ -69,6 +69,11 @@ public class InvokeAI : BaseGitPackage
[SharedFolderType.ControlNet] = new[] { RelativeRootPath + "/autoimport/controlnet" },
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new() { [SharedOutputType.Text2Img] = new[] { "invokeai-root/outputs/images" } };
public override string OutputFolderName => "invokeai-root/outputs/images";
// https://github.com/invoke-ai/InvokeAI/blob/main/docs/features/CONFIGURATION.md
public override List<LaunchOptionDefinition> LaunchOptions =>
new List<LaunchOptionDefinition>
@ -235,8 +240,6 @@ public class InvokeAI : BaseGitPackage
? $"https://github.com/invoke-ai/InvokeAI/archive/{latestVersion}.zip"
: $"https://github.com/invoke-ai/InvokeAI/archive/refs/heads/{installedPackage.Version.InstalledBranch}.zip";
var gpus = HardwareHelper.IterGpuInfo().ToList();
progress?.Report(new ProgressReport(-1f, "Installing Package", isIndeterminate: true));
var pipCommandArgs =

18
StabilityMatrix.Core/Models/Packages/UnknownPackage.cs

@ -26,6 +26,8 @@ public class UnknownPackage : BasePackage
public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink;
public override string OutputFolderName { get; }
public override Task DownloadPackage(
string installLocation,
DownloadPackageVersionOptions versionOptions,
@ -83,6 +85,16 @@ public class UnknownPackage : BasePackage
throw new NotImplementedException();
}
public override Task SetupOutputFolderLinks(DirectoryPath installDirectory)
{
throw new NotImplementedException();
}
public override Task RemoveOutputFolderLinks(DirectoryPath installDirectory)
{
throw new NotImplementedException();
}
public override IEnumerable<TorchVersion> AvailableTorchVersions =>
new[] { TorchVersion.Cuda, TorchVersion.Cpu, TorchVersion.Rocm, TorchVersion.DirectMl };
@ -122,6 +134,12 @@ public class UnknownPackage : BasePackage
public override List<LaunchOptionDefinition> LaunchOptions => new();
public override Dictionary<SharedFolderType, IReadOnlyList<string>>? SharedFolders { get; }
public override Dictionary<
SharedOutputType,
IReadOnlyList<string>
>? SharedOutputFolders { get; }
public override Task<string> GetLatestVersion() => Task.FromResult(string.Empty);
public override Task<PackageVersionOptions> GetAllVersionOptions() =>

13
StabilityMatrix.Core/Models/Packages/VladAutomatic.cs

@ -67,6 +67,19 @@ public class VladAutomatic : BaseGitPackage
[SharedFolderType.ControlNet] = new[] { "models/ControlNet" }
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new()
{
[SharedOutputType.Text2Img] = new[] { "outputs/text" },
[SharedOutputType.Img2Img] = new[] { "outputs/image" },
[SharedOutputType.Extras] = new[] { "outputs/extras" },
[SharedOutputType.Img2ImgGrids] = new[] { "outputs/grids" },
[SharedOutputType.Text2ImgGrids] = new[] { "outputs/grids" },
[SharedOutputType.Saved] = new[] { "outputs/save" },
};
public override string OutputFolderName => "outputs";
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
public override List<LaunchOptionDefinition> LaunchOptions =>
new()

10
StabilityMatrix.Core/Models/Packages/VoltaML.cs

@ -45,6 +45,16 @@ public class VoltaML : BaseGitPackage
[SharedFolderType.TextualInversion] = new[] { "data/textual-inversion" },
};
public override Dictionary<SharedOutputType, IReadOnlyList<string>>? SharedOutputFolders =>
new()
{
[SharedOutputType.Text2Img] = new[] { "data/outputs/txt2img" },
[SharedOutputType.Extras] = new[] { "data/outputs/extra" },
[SharedOutputType.Img2Img] = new[] { "data/outputs/img2img" },
};
public override string OutputFolderName => "data/outputs";
public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink;
public override IEnumerable<TorchVersion> AvailableTorchVersions => new[] { TorchVersion.None };

5
StabilityMatrix.Core/Models/Settings/Settings.cs

@ -70,6 +70,11 @@ public class Settings
/// </summary>
public bool IsCompletionRemoveUnderscoresEnabled { get; set; } = true;
/// <summary>
/// Format for Inference output image file names
/// </summary>
public string? InferenceOutputImageFileNameFormat { get; set; }
/// <summary>
/// Whether the Inference Image Viewer shows pixel grids at high zoom levels
/// </summary>

12
StabilityMatrix.Core/Models/SharedOutputType.cs

@ -0,0 +1,12 @@
namespace StabilityMatrix.Core.Models;
public enum SharedOutputType
{
All,
Text2Img,
Img2Img,
Extras,
Text2ImgGrids,
Img2ImgGrids,
Saved
}

28
StabilityMatrix.Tests/Avalonia/FileNameFormatProviderTests.cs

@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using StabilityMatrix.Avalonia.Models.Inference;
namespace StabilityMatrix.Tests.Avalonia;
[TestClass]
public class FileNameFormatProviderTests
{
[TestMethod]
public void TestFileNameFormatProviderValidate_Valid_ShouldNotThrow()
{
var provider = new FileNameFormatProvider();
var result = provider.Validate("{date}_{time}-{model_name}-{seed}");
Assert.AreEqual(ValidationResult.Success, result);
}
[TestMethod]
public void TestFileNameFormatProviderValidate_Invalid_ShouldThrow()
{
var provider = new FileNameFormatProvider();
var result = provider.Validate("{date}_{time}-{model_name}-{seed}-{invalid}");
Assert.AreNotEqual(ValidationResult.Success, result);
Assert.AreEqual("Unknown variable 'invalid'", result.ErrorMessage);
}
}

24
StabilityMatrix.Tests/Avalonia/FileNameFormatTests.cs

@ -0,0 +1,24 @@
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Tests.Avalonia;
[TestClass]
public class FileNameFormatTests
{
[TestMethod]
public void TestFileNameFormatParse()
{
var provider = new FileNameFormatProvider
{
GenerationParameters = new GenerationParameters { Seed = 123 },
ProjectName = "uwu",
ProjectType = InferenceProjectType.TextToImage,
};
var format = FileNameFormat.Parse("{project_type} - {project_name} ({seed})", provider);
Assert.AreEqual("TextToImage - uwu (123)", format.GetFileName());
}
}
Loading…
Cancel
Save