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.
672 lines
22 KiB
672 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; |
|
} |
|
}
|
|
|