Multi-Platform Package Manager for Stable Diffusion
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

673 lines
22 KiB

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using AsyncImageLoader;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DynamicData;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using Nito.Disposables.Internals;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.OutputsPage;
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 Size = StabilityMatrix.Core.Models.Settings.Size;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(Views.OutputsPage))]
[Singleton]
public partial class OutputsPageViewModel : PageViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly ILogger<OutputsPageViewModel> logger;
private readonly List<CancellationTokenSource> cancellationTokenSources = [];
public override string Title => Resources.Label_OutputsPageTitle;
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Grid, IsFilled = true };
public SourceCache<LocalImageFile, string> OutputsCache { get; } = new(file => file.AbsolutePath);
private SourceCache<PackageOutputCategory, string> categoriesCache = new(category => category.Path);
public IObservableCollection<OutputImageViewModel> Outputs { get; set; } =
new ObservableCollectionExtended<OutputImageViewModel>();
public IObservableCollection<PackageOutputCategory> Categories { get; set; } =
new ObservableCollectionExtended<PackageOutputCategory>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowOutputTypes))]
private PackageOutputCategory? selectedCategory;
[ObservableProperty]
private SharedOutputType? selectedOutputType;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NumImagesSelected))]
private int numItemsSelected;
[ObservableProperty]
private string searchQuery;
[ObservableProperty]
private Size imageSize = new(300, 300);
[ObservableProperty]
private bool isConsolidating;
[ObservableProperty]
private bool isLoading;
[ObservableProperty]
private bool showFolders;
[ObservableProperty]
private bool isChangingCategory;
public bool CanShowOutputTypes => SelectedCategory?.Name?.Equals("Shared Output Folder") ?? false;
public string NumImagesSelected =>
NumItemsSelected == 1
? Resources.Label_OneImageSelected
: string.Format(Resources.Label_NumImagesSelected, NumItemsSelected);
private string[] allowedExtensions = [".png", ".webp"];
private PackageOutputCategory? lastOutputCategory;
public OutputsPageViewModel(
ISettingsManager settingsManager,
IPackageFactory packageFactory,
INotificationService notificationService,
INavigationService<MainWindowViewModel> navigationService,
ILogger<OutputsPageViewModel> logger
)
{
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.notificationService = notificationService;
this.navigationService = navigationService;
this.logger = logger;
var searcher = new ImageSearcher();
// Observable predicate from SearchQuery changes
var searchPredicate = this.WhenPropertyChanged(vm => vm.SearchQuery)
.Throttle(TimeSpan.FromMilliseconds(100))!
.Select(property => searcher.GetPredicate(property.Value))
.AsObservable();
OutputsCache
.Connect()
.DeferUntilLoaded()
.Filter(searchPredicate)
.Transform(file => new OutputImageViewModel(file))
.Sort(
SortExpressionComparer<OutputImageViewModel>
.Descending(vm => vm.ImageFile.CreatedAt)
.ThenByDescending(vm => vm.ImageFile.FileName)
)
.Bind(Outputs)
.WhenPropertyChanged(p => p.IsSelected)
.Throttle(TimeSpan.FromMilliseconds(50))
.Subscribe(_ =>
{
NumItemsSelected = Outputs.Count(o => o.IsSelected);
});
categoriesCache.Connect().DeferUntilLoaded().Bind(Categories).Subscribe();
settingsManager.RelayPropertyFor(
this,
vm => vm.ImageSize,
settings => settings.OutputsImageSize,
delay: TimeSpan.FromMilliseconds(250)
);
settingsManager.RelayPropertyFor(
this,
vm => vm.ShowFolders,
settings => settings.IsOutputsTreeViewEnabled,
true
);
}
protected override void OnInitialLoaded()
{
if (Design.IsDesignMode)
return;
if (!settingsManager.IsLibraryDirSet)
return;
Directory.CreateDirectory(settingsManager.ImagesDirectory);
RefreshCategories();
SelectedCategory ??= Categories.First();
SelectedOutputType ??= SharedOutputType.All;
SearchQuery = string.Empty;
ImageSize = settingsManager.Settings.OutputsImageSize;
lastOutputCategory = SelectedCategory;
IsChangingCategory = true;
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 || oldValue == null || newValue == null)
return;
var path =
CanShowOutputTypes && SelectedOutputType != SharedOutputType.All
? Path.Combine(newValue.Path, SelectedOutputType.ToString())
: SelectedCategory.Path;
GetOutputs(path);
lastOutputCategory = newValue;
}
partial void OnSelectedOutputTypeChanged(SharedOutputType? oldValue, SharedOutputType? newValue)
{
if (oldValue == newValue || oldValue == null || newValue == null)
return;
var path =
newValue == SharedOutputType.All
? SelectedCategory?.Path
: Path.Combine(SelectedCategory.Path, newValue.ToString());
GetOutputs(path);
}
public Task OnImageClick(OutputImageViewModel item)
{
// Select image if we're in "select mode"
if (NumItemsSelected > 0)
{
item.IsSelected = !item.IsSelected;
}
else
{
return ShowImageDialog(item);
}
return Task.CompletedTask;
}
public async Task ShowImageDialog(OutputImageViewModel item)
{
var currentIndex = Outputs.IndexOf(item);
var image = new ImageSource(new FilePath(item.ImageFile.AbsolutePath));
// Preload
await image.GetBitmapAsync();
var vm = new ImageViewerViewModel { ImageSource = image, LocalImageFile = item.ImageFile };
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.ImageFile.AbsolutePath)
);
// Preload
await newImageSource.GetBitmapAsync();
sender.ImageSource = newImageSource;
sender.LocalImageFile = newImage.ImageFile;
currentIndex = newIndex;
}
})
.SafeFireAndForget();
});
await vm.GetDialog().ShowAsync();
}
public Task CopyImage(string imagePath)
{
var clipboard = App.Clipboard;
return clipboard.SetFileDataObjectAsync(imagePath);
}
public Task OpenImage(string imagePath) => ProcessRunner.OpenFileBrowser(imagePath);
public void Refresh()
{
Dispatcher.UIThread.Post(RefreshCategories);
var path =
CanShowOutputTypes && SelectedOutputType != SharedOutputType.All
? Path.Combine(SelectedCategory.Path, SelectedOutputType.ToString())
: SelectedCategory.Path;
GetOutputs(path);
}
public async Task DeleteImage(OutputImageViewModel? item)
{
if (item is null)
return;
var confirmationDialog = new BetterContentDialog
{
Title = Resources.Label_AreYouSure,
Content = Resources.Label_ActionCannotBeUndone,
PrimaryButtonText = Resources.Action_Delete,
SecondaryButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = true,
};
var dialogResult = await confirmationDialog.ShowAsync();
if (dialogResult != ContentDialogResult.Primary)
return;
// Delete the file
var imageFile = new FilePath(item.ImageFile.AbsolutePath);
var result = await notificationService.TryAsync(imageFile.DeleteAsync());
if (!result.IsSuccessful)
{
return;
}
//Attempt to remove .txt sidecar if it exists
var sideCar = new FilePath(Path.ChangeExtension(imageFile, ".txt"));
if (File.Exists(sideCar))
{
await notificationService.TryAsync(sideCar.DeleteAsync());
}
OutputsCache.Remove(item.ImageFile);
// Invalidate cache
if (ImageLoader.AsyncImageLoader is FallbackRamCachedWebImageLoader loader)
{
loader.RemoveAllNamesFromCache(imageFile.Name);
}
}
public void SendToTextToImage(OutputImageViewModel vm)
{
navigationService.NavigateTo<InferenceViewModel>();
EventManager.Instance.OnInferenceTextToImageRequested(vm.ImageFile);
}
public void SendToUpscale(OutputImageViewModel vm)
{
navigationService.NavigateTo<InferenceViewModel>();
EventManager.Instance.OnInferenceUpscaleRequested(vm.ImageFile);
}
public void SendToImageToImage(OutputImageViewModel vm)
{
navigationService.NavigateTo<InferenceViewModel>();
EventManager.Instance.OnInferenceImageToImageRequested(vm.ImageFile);
}
public void SendToImageToVideo(OutputImageViewModel vm)
{
navigationService.NavigateTo<InferenceViewModel>();
EventManager.Instance.OnInferenceImageToVideoRequested(vm.ImageFile);
}
public void ClearSelection()
{
foreach (var output in Outputs)
{
output.IsSelected = false;
}
}
public void SelectAll()
{
foreach (var output in Outputs)
{
output.IsSelected = true;
}
}
public async Task DeleteAllSelected()
{
var confirmationDialog = new BetterContentDialog
{
Title = string.Format(Resources.Label_AreYouSureDeleteImages, NumItemsSelected),
Content = Resources.Label_ActionCannotBeUndone,
PrimaryButtonText = Resources.Action_Delete,
SecondaryButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = true,
};
var dialogResult = await confirmationDialog.ShowAsync();
if (dialogResult != ContentDialogResult.Primary)
return;
var selected = Outputs.Where(o => o.IsSelected).ToList();
Debug.Assert(selected.Count == NumItemsSelected);
var imagesToRemove = new List<LocalImageFile>();
foreach (var output in selected)
{
// Delete the file
var imageFile = new FilePath(output.ImageFile.AbsolutePath);
var result = await notificationService.TryAsync(imageFile.DeleteAsync());
if (!result.IsSuccessful)
{
continue;
}
//Attempt to remove .txt sidecar if it exists
var sideCar = new FilePath(Path.ChangeExtension(imageFile, ".txt"));
if (File.Exists(sideCar))
{
await notificationService.TryAsync(sideCar.DeleteAsync());
}
imagesToRemove.Add(output.ImageFile);
}
OutputsCache.Remove(imagesToRemove);
NumItemsSelected = 0;
ClearSelection();
}
public async Task ConsolidateImages()
{
var stackPanel = new StackPanel();
stackPanel.Children.Add(
new TextBlock
{
Text = Resources.Label_ConsolidateExplanation,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 8, 0, 16)
}
);
foreach (var category in Categories)
{
if (category.Name == "Shared Output Folder")
{
continue;
}
stackPanel.Children.Add(
new CheckBox
{
Content = $"{category.Name} ({category.Path})",
IsChecked = true,
Margin = new Thickness(0, 8, 0, 0),
Tag = category.Path
}
);
}
var confirmationDialog = new BetterContentDialog
{
Title = Resources.Label_AreYouSure,
Content = stackPanel,
PrimaryButtonText = Resources.Action_Yes,
SecondaryButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = true,
};
var dialogResult = await confirmationDialog.ShowAsync();
if (dialogResult != ContentDialogResult.Primary)
return;
IsConsolidating = true;
Directory.CreateDirectory(settingsManager.ConsolidatedImagesDirectory);
foreach (var category in stackPanel.Children.OfType<CheckBox>().Where(c => c.IsChecked == true))
{
if (
string.IsNullOrWhiteSpace(category.Tag?.ToString())
|| !Directory.Exists(category.Tag?.ToString())
)
continue;
var directory = category.Tag.ToString();
foreach (var path in Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories))
{
try
{
var file = new FilePath(path);
if (!allowedExtensions.Contains(file.Extension))
continue;
var newPath = settingsManager.ConsolidatedImagesDirectory + file.Name;
if (file.FullPath == newPath)
continue;
// ignore inference if not in inference directory
if (
file.FullPath.Contains(settingsManager.ImagesInferenceDirectory)
&& directory != settingsManager.ImagesInferenceDirectory
)
{
continue;
}
await file.MoveToWithIncrementAsync(newPath);
var sideCar = new FilePath(Path.ChangeExtension(file, ".txt"));
//If a .txt sidecar file exists, and the image was moved successfully, try to move the sidecar along with the image
if (File.Exists(newPath) && File.Exists(sideCar))
{
var newSidecar = new FilePath(Path.ChangeExtension(newPath, ".txt"));
await sideCar.MoveToWithIncrementAsync(newSidecar);
}
}
catch (Exception e)
{
logger.LogError(e, "Error when consolidating: ");
}
}
}
Refresh();
IsConsolidating = false;
}
public void ClearSearchQuery()
{
SearchQuery = string.Empty;
}
private void GetOutputs(string directory)
{
if (!settingsManager.IsLibraryDirSet)
return;
if (
!Directory.Exists(directory)
&& (
SelectedCategory.Path != settingsManager.ImagesDirectory
|| SelectedOutputType != SharedOutputType.All
)
)
{
Directory.CreateDirectory(directory);
return;
}
if (lastOutputCategory?.Path.Equals(directory) is not true)
{
OutputsCache.Clear();
IsChangingCategory = true;
}
IsLoading = true;
cancellationTokenSources.ForEach(cts => cts.Cancel());
Task.Run(() =>
{
var getOutputsTokenSource = new CancellationTokenSource();
cancellationTokenSources.Add(getOutputsTokenSource);
if (getOutputsTokenSource.IsCancellationRequested)
{
cancellationTokenSources.Remove(getOutputsTokenSource);
return;
}
var files = Directory
.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories)
.Where(file => allowedExtensions.Contains(new FilePath(file).Extension))
.Select(file => LocalImageFile.FromPath(file))
.ToList();
if (getOutputsTokenSource.IsCancellationRequested)
{
cancellationTokenSources.Remove(getOutputsTokenSource);
return;
}
Dispatcher.UIThread.Post(() =>
{
if (files.Count == 0 && OutputsCache.Count == 0)
{
IsLoading = false;
IsChangingCategory = false;
return;
}
OutputsCache.EditDiff(
files,
(oldItem, newItem) => oldItem.AbsolutePath == newItem.AbsolutePath
);
IsLoading = false;
IsChangingCategory = false;
});
cancellationTokenSources.Remove(getOutputsTokenSource);
});
}
private void RefreshCategories()
{
if (Design.IsDesignMode)
return;
if (!settingsManager.IsLibraryDirSet)
return;
var previouslySelectedCategory = SelectedCategory;
var packageCategories = settingsManager
.Settings.InstalledPackages.Where(x => !x.UseSharedOutputFolder)
.Select(packageFactory.GetPackagePair)
.WhereNotNull()
.Where(
p =>
p.BasePackage.SharedOutputFolders is { Count: > 0 } && p.InstalledPackage.FullPath != null
)
.Select(
pair =>
new PackageOutputCategory
{
Path = Path.Combine(
pair.InstalledPackage.FullPath!,
pair.BasePackage.OutputFolderName
),
Name = pair.InstalledPackage.DisplayName ?? "",
SubDirectories = GetSubfolders(
Path.Combine(pair.InstalledPackage.FullPath!, pair.BasePackage.OutputFolderName)
)
}
)
.ToList();
packageCategories.Insert(
0,
new PackageOutputCategory
{
Path = settingsManager.ImagesDirectory,
Name = "Shared Output Folder",
SubDirectories = GetSubfolders(settingsManager.ImagesDirectory)
}
);
categoriesCache.EditDiff(packageCategories, (a, b) => a.Path == b.Path);
SelectedCategory = previouslySelectedCategory ?? Categories.First();
}
private ObservableCollection<PackageOutputCategory> GetSubfolders(string strPath)
{
var subfolders = new ObservableCollection<PackageOutputCategory>();
if (!Directory.Exists(strPath))
return subfolders;
var directories = Directory.EnumerateDirectories(strPath, "*", SearchOption.TopDirectoryOnly);
foreach (var dir in directories)
{
var category = new PackageOutputCategory { Name = Path.GetFileName(dir), Path = dir };
if (Directory.GetDirectories(dir, "*", SearchOption.TopDirectoryOnly).Length > 0)
{
category.SubDirectories = GetSubfolders(dir);
}
subfolders.Add(category);
}
return subfolders;
}
}