diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f31ecc..e5a785a7 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 0e03ec17..ff5e0be5 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -262,7 +262,8 @@ public sealed class App : Application .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); services.AddSingleton( provider => @@ -281,6 +282,7 @@ public sealed class App : Application provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService() }, FooterPages = { provider.GetRequiredService() } } @@ -393,6 +395,7 @@ public sealed class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Inference tabs services.AddTransient(); diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 8c3e1b73..50997c63 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -336,6 +336,9 @@ public static class DesignData public static LaunchPageViewModel LaunchPageViewModel => Services.GetRequiredService(); + public static OutputsPageViewModel OutputsPageViewModel => + Services.GetRequiredService(); + public static PackageManagerViewModel PackageManagerViewModel { get diff --git a/StabilityMatrix.Avalonia/DesignData/MockImageIndexService.cs b/StabilityMatrix.Avalonia/DesignData/MockImageIndexService.cs index d5ee1650..1d125e30 100644 --- a/StabilityMatrix.Avalonia/DesignData/MockImageIndexService.cs +++ b/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", } } ); diff --git a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs b/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs index a6303f21..e04f4f46 100644 --- a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs +++ b/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 - { - ["filename_prefix"] = "Inference/TextToImage", - ["images"] = builder.Connections.Image - } + ClassType = "PreviewImage", + Inputs = new Dictionary { ["images"] = builder.Connections.Image } } ); - builder.Connections.OutputNodes.Add(saveImage); + builder.Connections.OutputNodes.Add(previewImage); - return saveImage.Name; + return previewImage.Name; } } diff --git a/StabilityMatrix.Avalonia/Helpers/ImageProcessor.cs b/StabilityMatrix.Avalonia/Helpers/ImageProcessor.cs index 28c215d6..a090c6a2 100644 --- a/StabilityMatrix.Avalonia/Helpers/ImageProcessor.cs +++ b/StabilityMatrix.Avalonia/Helpers/ImageProcessor.cs @@ -13,50 +13,57 @@ public static class ImageProcessor /// 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 // and the column count will be floor of count / rows - var rows = (int) Math.Floor(Math.Sqrt(count)); - var columns = (int) Math.Floor((double) count / rows); + var rows = (int)Math.Floor(Math.Sqrt(count)); + var columns = (int)Math.Floor((double)count / rows); return (rows, columns); } - - public static SKImage CreateImageGrid( - IReadOnlyList images, - int spacing = 0) + + public static SKImage CreateImageGrid(IReadOnlyList 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; var singleHeight = images[0].Height; - + // Make output image using var output = new SKBitmap( - singleWidth * columns + spacing * (columns - 1), - singleHeight * rows + spacing * (rows - 1)); - + singleWidth * columns + spacing * (columns - 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]; - + // Draw image var destination = new SKRect( 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); } diff --git a/StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs b/StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs index 086d0785..aa31add4 100644 --- a/StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs +++ b/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, diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 73fc62e0..ab1c7e63 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -140,6 +140,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Copy. + /// + public static string Action_Copy { + get { + return ResourceManager.GetString("Action_Copy", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete. /// @@ -1526,6 +1535,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Output Sharing. + /// + public static string Label_UseSharedOutputFolder { + get { + return ResourceManager.GetString("Label_UseSharedOutputFolder", resourceCulture); + } + } + /// /// Looks up a localized string similar to VAE. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 7575d1d8..a0fd01c3 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -678,7 +678,13 @@ Restore Default Layout + + Output Sharing + Batch Index + + Copy + \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Models/Inference/FileNameFormat.cs b/StabilityMatrix.Avalonia/Models/Inference/FileNameFormat.cs new file mode 100644 index 00000000..b20f6c48 --- /dev/null +++ b/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 Parts { get; } + + private FileNameFormat(string template, IReadOnlyList 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}"; +} diff --git a/StabilityMatrix.Avalonia/Models/Inference/FileNameFormatPart.cs b/StabilityMatrix.Avalonia/Models/Inference/FileNameFormatPart.cs new file mode 100644 index 00000000..9210adc0 --- /dev/null +++ b/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 substitution; +} diff --git a/StabilityMatrix.Avalonia/Models/Inference/FileNameFormatProvider.cs b/StabilityMatrix.Avalonia/Models/Inference/FileNameFormatProvider.cs new file mode 100644 index 00000000..1422efa1 --- /dev/null +++ b/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>? _substitutions; + + public Dictionary> Substitutions => + _substitutions ??= new Dictionary> + { + { "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") } + }; + + /// + /// Validate a format string + /// + /// Format string + /// Thrown if the format string contains an unknown variable + [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 GetParts(string template) + { + var regex = BracketRegex(); + var matches = regex.Matches(template); + + var parts = new List(); + + // Loop through all parts of the string, including matches and non-matches + var currentIndex = 0; + + foreach (var result in matches.Cast()) + { + // 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; + } + + /// + /// Return a sample provider for UI preview + /// + public static FileNameFormatProvider GetSample() + { + return new FileNameFormatProvider + { + GenerationParameters = GenerationParameters.GetSample(), + ProjectType = InferenceProjectType.TextToImage, + ProjectName = "Sample Project" + }; + } + + /// + /// Extract variable and index from a combined string + /// + 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); + } + + /// + /// Regex for matching contents within a curly brace. + /// + [GeneratedRegex(@"\{([a-z_:\d\[\]]+)\}")] + private static partial Regex BracketRegex(); + + /// + /// Regex for matching a Python-like array index. + /// + [GeneratedRegex(@"\[(?:(?-?\d+)?)\:(?:(?-?\d+)?)?(?:\:(?-?\d+))?\]")] + private static partial Regex IndexRegex(); + + private record Slice(int? Start, int? End, int? Step); +} diff --git a/StabilityMatrix.Avalonia/Models/Inference/FileNameFormatVar.cs b/StabilityMatrix.Avalonia/Models/Inference/FileNameFormatVar.cs new file mode 100644 index 00000000..a453b3bc --- /dev/null +++ b/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; } +} diff --git a/StabilityMatrix.Avalonia/Models/PackageOutputCategory.cs b/StabilityMatrix.Avalonia/Models/PackageOutputCategory.cs new file mode 100644 index 00000000..2318d0f8 --- /dev/null +++ b/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; } +} diff --git a/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs b/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs index 3e71a877..17023dfa 100644 --- a/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs +++ b/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 + ); + } + } + } + /// 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(); diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index ea70b345..a6ed93d0 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -8,7 +8,7 @@ app.manifest true ./Assets/Icon.ico - 2.5.3-dev.1 + 2.6.0-dev.1 $(Version) true true @@ -32,6 +32,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs index 3bd7e614..d3b746f6 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs +++ b/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 vmFactory; @@ -60,11 +65,13 @@ public abstract partial class InferenceGenerationViewModelBase protected InferenceGenerationViewModelBase( ServiceManager 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); } + /// + /// Write an image to the default output folder + /// + protected Task 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 + ); + } + + /// + /// Write an image to an output folder + /// + protected async Task 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; + } + /// /// Builds the image generation prompt /// @@ -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(); var outputImages = new List(); - foreach ( - var (i, filePath) in images - .Select(image => image.ToFilePath(args.Client.OutputImagesDir!)) - .Enumerate() - ) + + foreach (var (i, comfyImage) in images.Enumerate()) { - if (!filePath.Exists) - { - 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); diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs index b31b5501..02c31627 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs +++ b/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; diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs index 9e56a16d..d97cf780 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs +++ b/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 vmFactory ) - : base(vmFactory, inferenceClientManager, notificationService) + : base(vmFactory, inferenceClientManager, notificationService, settingsManager) { this.notificationService = notificationService; diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs index 07124ac0..929aa771 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs @@ -86,10 +86,11 @@ public class InferenceTextToImageViewModel public InferenceTextToImageViewModel( INotificationService notificationService, IInferenceClientManager inferenceClientManager, + ISettingsManager settingsManager, ServiceManager vmFactory, IModelIndexService modelIndexService ) - : base(vmFactory, inferenceClientManager, notificationService) + : base(vmFactory, inferenceClientManager, notificationService, settingsManager) { this.notificationService = notificationService; this.modelIndexService = modelIndexService; diff --git a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs index 4b2ff874..47513a99 100644 --- a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs +++ b/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()); + } + /// /// Update the database with current tabs /// @@ -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(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(); + textToImage.LoadStateFromJsonObject(document.State); + vm = textToImage; + } + else if (!string.IsNullOrWhiteSpace(metadata.Parameters)) + { + if (GenerationParameters.TryParse(metadata.Parameters, out var generationParameters)) + { + var textToImageViewModel = vmFactory.Get(); + 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(); + upscaleVm.IsUpscaleEnabled = true; + upscaleVm.SelectImageCardViewModel.ImageSource = new ImageSource(imageFile.AbsolutePath); + + Tabs.Add(upscaleVm); + SelectedTab = upscaleVm; + + await SyncTabStatesWithDatabase(); + } + /// /// Menu "Open Project" command. /// diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs new file mode 100644 index 00000000..209fd099 --- /dev/null +++ b/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 OutputsCache { get; } = new(p => p.AbsolutePath); + + public IObservableCollection Outputs { get; } = + new ObservableCollectionExtended(); + + public IEnumerable OutputTypes { get; } = Enum.GetValues(); + + [ObservableProperty] + private ObservableCollection 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(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( + 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(); + EventManager.Instance.OnInferenceTextToImageRequested(image); + } + + public void SendToUpscale(LocalImageFile image) + { + navigationService.NavigateTo(); + 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); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs index a733bbdd..cca0b036 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs +++ b/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 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) { diff --git a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs index eee148fa..850a397c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs +++ b/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 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 diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml index 1ab9fde9..39152677 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml @@ -106,6 +106,20 @@ Grid.Row="1" Text="{Binding NegativePrompt}" /> + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml new file mode 100644 index 00000000..1c200b5d --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs new file mode 100644 index 00000000..e14c5818 --- /dev/null +++ b/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(); + } +} diff --git a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml index d2d60e39..7ab65538 100644 --- a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml +++ b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml @@ -97,6 +97,16 @@ + + + + + diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml index 21ab36ac..18d65bb8 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml +++ b/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 @@ - + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Core/Helper/EventManager.cs b/StabilityMatrix.Core/Helper/EventManager.cs index 10e730d7..72a2b3c3 100644 --- a/StabilityMatrix.Core/Helper/EventManager.cs +++ b/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? ImageFileAdded; + public event EventHandler? InferenceTextToImageRequested; + public event EventHandler? 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); } diff --git a/StabilityMatrix.Core/Helper/SharedFolders.cs b/StabilityMatrix.Core/Helper/SharedFolders.cs index aeb67c29..e9406527 100644 --- a/StabilityMatrix.Core/Helper/SharedFolders.cs +++ b/StabilityMatrix.Core/Helper/SharedFolders.cs @@ -45,10 +45,12 @@ public class SharedFolders : ISharedFolders /// Shared source (i.e. "Models/") /// Destination (i.e. "webui/models/lora") /// Whether to overwrite the destination if it exists + /// Whether to recursively delete the directory after moving data out of it 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. /// - public static async Task UpdateLinksForPackage( - Dictionary> sharedFolders, + public static async Task UpdateLinksForPackage( + Dictionary> 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( + Dictionary>? sharedFolders, + DirectoryPath installPath + ) + where T : Enum { - var sharedFolders = package.SharedFolders; if (sharedFolders == null) { return; diff --git a/StabilityMatrix.Core/Models/Database/LocalImageFile.cs b/StabilityMatrix.Core/Models/Database/LocalImageFile.cs index 73639506..bf728a1b 100644 --- a/StabilityMatrix.Core/Models/Database/LocalImageFile.cs +++ b/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; } + /// /// Type of the model file. /// @@ -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, diff --git a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs index 53873549..c73b2d8f 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs @@ -161,7 +161,21 @@ public class FilePath : FileSystemPath, IPathObject /// public async Task 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; } diff --git a/StabilityMatrix.Core/Models/GenerationParameters.cs b/StabilityMatrix.Core/Models/GenerationParameters.cs index fab9f793..9ab6c778 100644 --- a/StabilityMatrix.Core/Models/GenerationParameters.cs +++ b/StabilityMatrix.Core/Models/GenerationParameters.cs @@ -126,6 +126,26 @@ public partial record GenerationParameters return (sampler, scheduler); } + /// + /// Return a sample parameters for UI preview + /// + 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: (?\d+), Sampler: (?.+?), CFG scale: (?\d+(\.\d+)?), Seed: (?\d+), Size: (?\d+)x(?\d+), Model hash: (?.+?), Model: (?.+)$""" diff --git a/StabilityMatrix.Core/Models/InstalledPackage.cs b/StabilityMatrix.Core/Models/InstalledPackage.cs index 4695bb12..557d07ab 100644 --- a/StabilityMatrix.Core/Models/InstalledPackage.cs +++ b/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; } /// /// Get the launch args host option value. diff --git a/StabilityMatrix.Core/Models/Packages/A3WebUI.cs b/StabilityMatrix.Core/Models/Packages/A3WebUI.cs index 315c78a1..3646321d 100644 --- a/StabilityMatrix.Core/Models/Packages/A3WebUI.cs +++ b/StabilityMatrix.Core/Models/Packages/A3WebUI.cs @@ -61,6 +61,17 @@ public class A3WebUI : BaseGitPackage [SharedFolderType.AfterDetailer] = new[] { "models/adetailer" } }; + public override Dictionary>? 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 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, diff --git a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs index dab562cc..9af5e597 100644 --- a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs +++ b/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; } diff --git a/StabilityMatrix.Core/Models/Packages/BasePackage.cs b/StabilityMatrix.Core/Models/Packages/BasePackage.cs index 2c224ad3..546bd4d7 100644 --- a/StabilityMatrix.Core/Models/Packages/BasePackage.cs +++ b/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 AvailableTorchVersions { get; } public virtual TorchVersion GetRecommendedTorchVersion() @@ -142,7 +147,11 @@ public abstract class BasePackage /// The shared folders that this package supports. /// Mapping of to the relative paths from the package root. /// - public virtual Dictionary>? SharedFolders { get; } + public abstract Dictionary>? SharedFolders { get; } + public abstract Dictionary< + SharedOutputType, + IReadOnlyList + >? SharedOutputFolders { get; } public abstract Task GetLatestVersion(); public abstract Task GetAllVersionOptions(); diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index af3881fb..6992e1ac 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/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>? SharedOutputFolders => + new() { [SharedOutputType.Text2Img] = new[] { "output" } }; + public override List LaunchOptions => new List { diff --git a/StabilityMatrix.Core/Models/Packages/DankDiffusion.cs b/StabilityMatrix.Core/Models/Packages/DankDiffusion.cs index ea1e34b5..74405d92 100644 --- a/StabilityMatrix.Core/Models/Packages/DankDiffusion.cs +++ b/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 LaunchOptions { get; } + public override Dictionary>? SharedFolders { get; } + public override Dictionary< + SharedOutputType, + IReadOnlyList + >? SharedOutputFolders { get; } + public override Task GetLatestVersion() { throw new NotImplementedException(); diff --git a/StabilityMatrix.Core/Models/Packages/Fooocus.cs b/StabilityMatrix.Core/Models/Packages/Fooocus.cs index 424f3553..babe042a 100644 --- a/StabilityMatrix.Core/Models/Packages/Fooocus.cs +++ b/StabilityMatrix.Core/Models/Packages/Fooocus.cs @@ -83,6 +83,9 @@ public class Fooocus : BaseGitPackage [SharedFolderType.Hypernetwork] = new[] { "models/hypernetworks" } }; + public override Dictionary>? SharedOutputFolders => + new() { [SharedOutputType.Text2Img] = new[] { "outputs" } }; + public override IEnumerable 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, diff --git a/StabilityMatrix.Core/Models/Packages/FooocusMre.cs b/StabilityMatrix.Core/Models/Packages/FooocusMre.cs index fb65ce28..29ed35c2 100644 --- a/StabilityMatrix.Core/Models/Packages/FooocusMre.cs +++ b/StabilityMatrix.Core/Models/Packages/FooocusMre.cs @@ -85,6 +85,9 @@ public class FooocusMre : BaseGitPackage [SharedFolderType.Hypernetwork] = new[] { "models/hypernetworks" } }; + public override Dictionary>? SharedOutputFolders => + new() { [SharedOutputType.Text2Img] = new[] { "outputs" } }; + public override IEnumerable 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, diff --git a/StabilityMatrix.Core/Models/Packages/InvokeAI.cs b/StabilityMatrix.Core/Models/Packages/InvokeAI.cs index 0284143c..bd7d120f 100644 --- a/StabilityMatrix.Core/Models/Packages/InvokeAI.cs +++ b/StabilityMatrix.Core/Models/Packages/InvokeAI.cs @@ -69,6 +69,11 @@ public class InvokeAI : BaseGitPackage [SharedFolderType.ControlNet] = new[] { RelativeRootPath + "/autoimport/controlnet" }, }; + public override Dictionary>? 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 LaunchOptions => new List @@ -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 = diff --git a/StabilityMatrix.Core/Models/Packages/UnknownPackage.cs b/StabilityMatrix.Core/Models/Packages/UnknownPackage.cs index 5be7baf0..ca838c42 100644 --- a/StabilityMatrix.Core/Models/Packages/UnknownPackage.cs +++ b/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 AvailableTorchVersions => new[] { TorchVersion.Cuda, TorchVersion.Cpu, TorchVersion.Rocm, TorchVersion.DirectMl }; @@ -122,6 +134,12 @@ public class UnknownPackage : BasePackage public override List LaunchOptions => new(); + public override Dictionary>? SharedFolders { get; } + public override Dictionary< + SharedOutputType, + IReadOnlyList + >? SharedOutputFolders { get; } + public override Task GetLatestVersion() => Task.FromResult(string.Empty); public override Task GetAllVersionOptions() => diff --git a/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs b/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs index 08fd6fe9..2b6bd3e4 100644 --- a/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs +++ b/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs @@ -67,6 +67,19 @@ public class VladAutomatic : BaseGitPackage [SharedFolderType.ControlNet] = new[] { "models/ControlNet" } }; + public override Dictionary>? 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 LaunchOptions => new() diff --git a/StabilityMatrix.Core/Models/Packages/VoltaML.cs b/StabilityMatrix.Core/Models/Packages/VoltaML.cs index b8ff9733..638092b6 100644 --- a/StabilityMatrix.Core/Models/Packages/VoltaML.cs +++ b/StabilityMatrix.Core/Models/Packages/VoltaML.cs @@ -45,6 +45,16 @@ public class VoltaML : BaseGitPackage [SharedFolderType.TextualInversion] = new[] { "data/textual-inversion" }, }; + public override Dictionary>? 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 AvailableTorchVersions => new[] { TorchVersion.None }; diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index cb01f14e..73d02cb6 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -70,6 +70,11 @@ public class Settings /// public bool IsCompletionRemoveUnderscoresEnabled { get; set; } = true; + /// + /// Format for Inference output image file names + /// + public string? InferenceOutputImageFileNameFormat { get; set; } + /// /// Whether the Inference Image Viewer shows pixel grids at high zoom levels /// diff --git a/StabilityMatrix.Core/Models/SharedOutputType.cs b/StabilityMatrix.Core/Models/SharedOutputType.cs new file mode 100644 index 00000000..889c5ea7 --- /dev/null +++ b/StabilityMatrix.Core/Models/SharedOutputType.cs @@ -0,0 +1,12 @@ +namespace StabilityMatrix.Core.Models; + +public enum SharedOutputType +{ + All, + Text2Img, + Img2Img, + Extras, + Text2ImgGrids, + Img2ImgGrids, + Saved +} diff --git a/StabilityMatrix.Tests/Avalonia/FileNameFormatProviderTests.cs b/StabilityMatrix.Tests/Avalonia/FileNameFormatProviderTests.cs new file mode 100644 index 00000000..5905aca0 --- /dev/null +++ b/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); + } +} diff --git a/StabilityMatrix.Tests/Avalonia/FileNameFormatTests.cs b/StabilityMatrix.Tests/Avalonia/FileNameFormatTests.cs new file mode 100644 index 00000000..0da1eb84 --- /dev/null +++ b/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()); + } +}