Browse Source

added MetadataImportService for handling model metadata imports & added the ability to "find connected metadata" for any file/folder/etc

pull/324/head
JT 12 months ago
parent
commit
4815c812b9
  1. 8
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  2. 36
      StabilityMatrix.Avalonia/DesignData/MockMetadataImportService.cs
  3. 11
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  4. 5
      StabilityMatrix.Avalonia/Languages/Resources.resx
  5. 63
      StabilityMatrix.Avalonia/Styles/SplitButtonStyles.axaml
  6. 51
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
  7. 61
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs
  8. 58
      StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
  9. 2
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml
  10. 91
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  11. 2
      StabilityMatrix.Avalonia/Views/Dialogs/InferenceConnectionHelpDialog.axaml
  12. 151
      StabilityMatrix.Avalonia/Views/HuggingFacePage.axaml
  13. 2
      StabilityMatrix.Core/Helper/ModelFinder.cs
  14. 4
      StabilityMatrix.Core/Models/ConnectedModelInfo.cs
  15. 6
      StabilityMatrix.Core/Models/Database/LocalModelFile.cs
  16. 24
      StabilityMatrix.Core/Services/IMetadataImportService.cs
  17. 308
      StabilityMatrix.Core/Services/MetadataImportService.cs

8
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -124,7 +124,8 @@ public static class DesignData
.AddSingleton<IInferenceClientManager, MockInferenceClientManager>()
.AddSingleton<ICompletionProvider, MockCompletionProvider>()
.AddSingleton<IModelIndexService, MockModelIndexService>()
.AddSingleton<IImageIndexService, MockImageIndexService>();
.AddSingleton<IImageIndexService, MockImageIndexService>()
.AddSingleton<IMetadataImportService, MetadataImportService>();
// Placeholder services that nobody should need during design time
services
@ -147,6 +148,7 @@ public static class DesignData
var modelFinder = Services.GetRequiredService<ModelFinder>();
var packageFactory = Services.GetRequiredService<IPackageFactory>();
var notificationService = Services.GetRequiredService<INotificationService>();
var modelImportService = Services.GetRequiredService<IMetadataImportService>();
LaunchOptionsViewModel = Services.GetRequiredService<LaunchOptionsViewModel>();
LaunchOptionsViewModel.Cards = new[]
@ -183,7 +185,7 @@ public static class DesignData
CheckpointsPageViewModel.CheckpointFoldersCache,
new CheckpointFolder[]
{
new(settingsManager, downloadService, modelFinder, notificationService)
new(settingsManager, downloadService, modelFinder, notificationService, modelImportService)
{
DirectoryPath = "Models/StableDiffusion",
DisplayedCheckpointFiles = new ObservableCollectionExtended<CheckpointFile>()
@ -217,7 +219,7 @@ public static class DesignData
},
},
},
new(settingsManager, downloadService, modelFinder, notificationService)
new(settingsManager, downloadService, modelFinder, notificationService, modelImportService)
{
Title = "Lora",
DirectoryPath = "Packages/Lora",

36
StabilityMatrix.Avalonia/DesignData/MockMetadataImportService.cs

@ -0,0 +1,36 @@
using System;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockMetadataImportService : IMetadataImportService
{
public Task ScanDirectoryForMissingInfo(
DirectoryPath directory,
IProgress<ProgressReport>? progress = null
)
{
return Task.CompletedTask;
}
public Task<ConnectedModelInfo?> GetMetadataForFile(
FilePath filePath,
IProgress<ProgressReport>? progress = null,
bool forceReimport = false
)
{
return null;
}
public Task UpdateExistingMetadata(
DirectoryPath directory,
IProgress<ProgressReport>? progress = null
)
{
return Task.CompletedTask;
}
}

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

@ -572,6 +572,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Update Existing Metadata.
/// </summary>
public static string Action_UpdateExistingMetadata {
get {
return ResourceManager.GetString("Action_UpdateExistingMetadata", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Upgrade.
/// </summary>
@ -1014,7 +1023,7 @@ namespace StabilityMatrix.Avalonia.Languages {
}
/// <summary>
/// Looks up a localized string similar to Emebeddings / Textual Inversion.
/// Looks up a localized string similar to Embeddings / Textual Inversion.
/// </summary>
public static string Label_EmbeddingsOrTextualInversion {
get {

5
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -175,7 +175,7 @@
<value>Deemphasis</value>
</data>
<data name="Label_EmbeddingsOrTextualInversion" xml:space="preserve">
<value>Emebeddings / Textual Inversion</value>
<value>Embeddings / Textual Inversion</value>
</data>
<data name="Label_NetworksLoraOrLycoris" xml:space="preserve">
<value>Networks (Lora / LyCORIS)</value>
@ -816,4 +816,7 @@
<data name="Action_OpenOnHuggingFace" xml:space="preserve">
<value>Open on Hugging Face</value>
</data>
<data name="Action_UpdateExistingMetadata" xml:space="preserve">
<value>Update Existing Metadata</value>
</data>
</root>

63
StabilityMatrix.Avalonia/Styles/SplitButtonStyles.axaml

@ -11,6 +11,7 @@
<SplitButton Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" />
<SplitButton Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<SplitButton Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<SplitButton Classes="transparent-full" Content="Full Transparent Button" Margin="8" HorizontalAlignment="Center" />
<SplitButton Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel>
</Border>
@ -417,6 +418,68 @@
</Style>
</Style>
<!-- Transparent-Full -->
<Style Selector="SplitButton.transparent-full">
<Style Selector="^ /template/ Button /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
</Style>
<Style Selector="^ /template/ Button /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<!-- Pseudo-classes -->
<Style Selector="^ /template/ Button#PART_PrimaryButton:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^ /template/ Button#PART_SecondaryButton:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^ /template/ Button#PART_PrimaryButton:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^ /template/ Button#PART_SecondaryButton:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ Button /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Semi-Transparent Info -->
<Style Selector="SplitButton.transparent-info">
<Style Selector="^ /template/ Button /template/ ui|FABorder#Root">

51
StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs

@ -18,6 +18,7 @@ using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
@ -46,6 +47,7 @@ public partial class CheckpointFile : ViewModelBase
private string? previewImagePath;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsConnectedModel))]
private ConnectedModelInfo? connectedModel;
public bool IsConnectedModel => ConnectedModel != null;
@ -58,6 +60,9 @@ public partial class CheckpointFile : ViewModelBase
[ObservableProperty]
private CheckpointFolder parentFolder;
[ObservableProperty]
private ProgressReport? progress;
public string FileName => Path.GetFileName(FilePath);
public bool CanShowTriggerWords =>
@ -231,6 +236,50 @@ public partial class CheckpointFile : ViewModelBase
return App.Clipboard.SetTextAsync(words);
}
[RelayCommand]
private async Task FindConnectedMetadata(bool forceReimport = false)
{
if (
App.Services.GetService(typeof(IMetadataImportService))
is not IMetadataImportService importService
)
return;
IsLoading = true;
try
{
var progressReport = new Progress<ProgressReport>(report =>
{
Progress = report;
});
var cmInfo = await importService.GetMetadataForFile(
FilePath,
progressReport,
forceReimport
);
if (cmInfo != null)
{
ConnectedModel = cmInfo;
PreviewImagePath = SupportedImageExtensions
.Select(
ext =>
Path.Combine(
ParentFolder.DirectoryPath,
$"{Path.GetFileNameWithoutExtension(FileName)}.preview{ext}"
)
)
.Where(File.Exists)
.FirstOrDefault();
}
}
finally
{
IsLoading = false;
}
}
/// <summary>
/// Indexes directory and yields all checkpoint files.
/// First we match all files with supported extensions.
@ -316,7 +365,7 @@ public partial class CheckpointFile : ViewModelBase
var checkpointFile = new CheckpointFile
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = file,
FilePath = file
};
var jsonPath = Path.Combine(

61
StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs

@ -37,6 +37,7 @@ public partial class CheckpointFolder : ViewModelBase
private readonly IDownloadService downloadService;
private readonly ModelFinder modelFinder;
private readonly INotificationService notificationService;
private readonly IMetadataImportService metadataImportService;
public SourceCache<CheckpointFolder, string> SubFoldersCache { get; } =
new(x => x.DirectoryPath);
@ -111,6 +112,7 @@ public partial class CheckpointFolder : ViewModelBase
IDownloadService downloadService,
ModelFinder modelFinder,
INotificationService notificationService,
IMetadataImportService metadataImportService,
bool useCategoryVisibility = true
)
{
@ -118,6 +120,7 @@ public partial class CheckpointFolder : ViewModelBase
this.downloadService = downloadService;
this.modelFinder = modelFinder;
this.notificationService = notificationService;
this.metadataImportService = metadataImportService;
this.useCategoryVisibility = useCategoryVisibility;
checkpointFilesCache
@ -148,6 +151,12 @@ public partial class CheckpointFolder : ViewModelBase
SubFoldersCache
.Connect()
.DeferUntilLoaded()
.SubscribeMany(
folder =>
Observable
.FromEventPattern<EventArgs>(folder, nameof(ParentListRemoveRequested))
.Subscribe(_ => SubFoldersCache.Remove(folder))
)
.SortBy(x => x.Title)
.Bind(SubFolders)
.Subscribe();
@ -306,12 +315,13 @@ public partial class CheckpointFolder : ViewModelBase
Directory.CreateDirectory(subFolderPath);
SubFolders.Add(
SubFoldersCache.AddOrUpdate(
new CheckpointFolder(
settingsManager,
downloadService,
modelFinder,
notificationService,
metadataImportService,
useCategoryVisibility: false
)
{
@ -393,11 +403,7 @@ public partial class CheckpointFolder : ViewModelBase
/// <summary>
/// Imports files to the folder. Reports progress to instance properties.
/// </summary>
public async Task ImportFilesAsync(
IEnumerable<string> files,
bool convertToConnected = false,
bool copyFiles = true
)
public async Task ImportFilesAsync(IEnumerable<string> files, bool convertToConnected = false)
{
try
{
@ -418,10 +424,7 @@ public partial class CheckpointFolder : ViewModelBase
: $"Importing {report.Title}";
});
if (copyFiles)
{
await FileTransfers.CopyFiles(copyPaths, progress);
}
await FileTransfers.CopyFiles(copyPaths, progress);
// Hash files and convert them to connected model if found
if (convertToConnected)
@ -536,17 +539,32 @@ public partial class CheckpointFolder : ViewModelBase
.Select(f => f.FilePath)
.ToList();
if (files.Any())
try
{
await ImportFilesAsync(files, true, false);
if (files.Count > 0)
{
var progress = new Progress<ProgressReport>(report =>
{
Progress.IsIndeterminate = report.IsIndeterminate;
Progress.Value = report.Percentage;
Progress.Text = report.Title;
});
await metadataImportService.ScanDirectoryForMissingInfo(DirectoryPath, progress);
BackgroundIndex();
notificationService.Show("Scan Complete", "Finished scanning for missing metadata");
}
else
{
notificationService.Show(
"Cannot Find Connected Metadata",
"All files in this folder are already connected.",
NotificationType.Warning
);
}
}
else
finally
{
notificationService.Show(
"Cannot Find Connected Metadata",
"All files in this folder are already connected.",
NotificationType.Warning
);
DelayedClearProgress(TimeSpan.FromSeconds(1.5));
}
}
@ -591,6 +609,7 @@ public partial class CheckpointFolder : ViewModelBase
/// </summary>
public void Index()
{
var updatedFolders = new List<CheckpointFolder>();
// Get subfolders
foreach (var folder in Directory.GetDirectories(DirectoryPath))
{
@ -598,6 +617,7 @@ public partial class CheckpointFolder : ViewModelBase
if (SubFoldersCache.Lookup(folder) is { HasValue: true } result)
{
result.Value.BackgroundIndex();
updatedFolders.Add(result.Value);
}
else
{
@ -607,6 +627,7 @@ public partial class CheckpointFolder : ViewModelBase
downloadService,
modelFinder,
notificationService,
metadataImportService,
useCategoryVisibility: false
)
{
@ -617,10 +638,12 @@ public partial class CheckpointFolder : ViewModelBase
IsExpanded = false, // Subfolders are collapsed by default
};
subFolder.BackgroundIndex();
SubFoldersCache.AddOrUpdate(subFolder);
updatedFolders.Add(subFolder);
}
}
SubFoldersCache.EditDiff(updatedFolders, (a, b) => a.Title == b.Title);
// Index files
Dispatcher.UIThread.Post(
() =>

58
StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData;
@ -16,6 +18,7 @@ using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
@ -34,6 +37,7 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
private readonly ModelFinder modelFinder;
private readonly IDownloadService downloadService;
private readonly INotificationService notificationService;
private readonly IMetadataImportService metadataImportService;
public override string Title => "Checkpoints";
@ -59,6 +63,9 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
[ObservableProperty]
private bool isCategoryTipOpen;
[ObservableProperty]
private ProgressReport progress;
partial void OnIsImportAsConnectedChanged(bool value)
{
if (
@ -83,6 +90,7 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
ISettingsManager settingsManager,
IDownloadService downloadService,
INotificationService notificationService,
IMetadataImportService metadataImportService,
ModelFinder modelFinder
)
{
@ -90,6 +98,7 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
this.settingsManager = settingsManager;
this.downloadService = downloadService;
this.notificationService = notificationService;
this.metadataImportService = metadataImportService;
this.modelFinder = modelFinder;
CheckpointFoldersCache
@ -195,14 +204,16 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
var sw = Stopwatch.StartNew();
// Index all folders
var updatedFolders = new List<CheckpointFolder>();
// Index all folders
foreach (var folder in folders)
{
// Get from cache or create new
if (CheckpointFoldersCache.Lookup(folder) is { HasValue: true } result)
{
result.Value.Index();
updatedFolders.Add(result.Value);
}
else
{
@ -210,7 +221,8 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
settingsManager,
downloadService,
modelFinder,
notificationService
notificationService,
metadataImportService
)
{
Title = Path.GetFileName(folder),
@ -218,10 +230,12 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
IsExpanded = true // Top level folders expanded by default
};
checkpointFolder.Index();
CheckpointFoldersCache.AddOrUpdate(checkpointFolder);
updatedFolders.Add(checkpointFolder);
}
}
CheckpointFoldersCache.EditDiff(updatedFolders, (a, b) => a.Title == b.Title);
sw.Stop();
Logger.Info($"IndexFolders in {sw.Elapsed.TotalMilliseconds:F1}ms");
}
@ -231,4 +245,42 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
{
await ProcessRunner.OpenFolderBrowser(settingsManager.ModelsDirectory);
}
[RelayCommand]
private async Task FindConnectedMetadata()
{
var progressHandler = new Progress<ProgressReport>(report =>
{
Progress = report;
});
await metadataImportService.ScanDirectoryForMissingInfo(
settingsManager.ModelsDirectory,
progressHandler
);
notificationService.Show(
"Scan Complete",
"Finished scanning for missing metadata.",
NotificationType.Success
);
}
[RelayCommand]
private async Task UpdateExistingMetadata()
{
var progressHandler = new Progress<ProgressReport>(report =>
{
Progress = report;
});
await metadataImportService.UpdateExistingMetadata(
settingsManager.ModelsDirectory,
progressHandler
);
notificationService.Show(
"Scan Complete",
"Finished updating metadata.",
NotificationType.Success
);
}
}

2
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml

@ -279,7 +279,7 @@
Foreground="{DynamicResource TextControlForeground}"
LetterSpacing="0.33"
Text="{Binding CivitModel.Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
ToolTip.Tip="{Binding CivitModel.Name}" />

91
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml

@ -56,14 +56,13 @@
</Interaction.Behaviors>
<controls:Card MaxWidth="330" Width="330"
Padding="0"
CornerRadius="12">
<!-- Right click menu for a checkpoint file -->
<controls:Card.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem Command="{Binding RenameCommand}"
Text="{x:Static lang:Resources.Action_Rename}" IconSource="Rename" />
<ui:MenuFlyoutItem Command="{Binding DeleteCommand}"
Text="{x:Static lang:Resources.Action_Delete}" IconSource="Delete" />
<ui:MenuFlyoutItem Command="{Binding OpenOnCivitAiCommand}"
Text="{x:Static lang:Resources.Action_OpenOnCivitAi}" IconSource="Link"
IsVisible="{Binding IsConnectedModel}" />
@ -71,11 +70,31 @@
IsVisible="{Binding CanShowTriggerWords}"
Text="{x:Static lang:Resources.Action_CopyTriggerWords}"
IconSource="Copy" />
<ui:MenuFlyoutItem Text="{x:Static lang:Resources.Label_FindConnectedMetadata}"
IconSource="Find"
Command="{Binding FindConnectedMetadataCommand}"
IsVisible="{Binding !IsConnectedModel}">
<ui:MenuFlyoutItem.CommandParameter>
<system:Boolean>False</system:Boolean>
</ui:MenuFlyoutItem.CommandParameter>
</ui:MenuFlyoutItem>
<ui:MenuFlyoutItem Text="{x:Static lang:Resources.Action_UpdateExistingMetadata}"
IconSource="Sync"
Command="{Binding FindConnectedMetadataCommand}"
IsVisible="{Binding IsConnectedModel}">
<ui:MenuFlyoutItem.CommandParameter>
<system:Boolean>True</system:Boolean>
</ui:MenuFlyoutItem.CommandParameter>
</ui:MenuFlyoutItem>
<ui:MenuFlyoutItem Command="{Binding DeleteCommand}"
Text="{x:Static lang:Resources.Action_Delete}"
IconSource="Delete" />
</ui:FAMenuFlyout>
</controls:Card.ContextFlyout>
<Grid>
<Grid RowDefinitions="Auto, Auto">
<!-- Main contents, hidden when IsLoading is true -->
<StackPanel MinHeight="70">
<StackPanel Grid.Row="0" Grid.RowSpan="2"
MinHeight="70" Margin="14, 16, 14, 16">
<Grid ColumnDefinitions="*, Auto" RowDefinitions="Auto,*,Auto,Auto"
IsVisible="{Binding !IsLoading}">
<StackPanel
@ -229,7 +248,20 @@
</TextBlock>
</Grid>
</StackPanel>
<Border Grid.Row="0"
Grid.RowSpan="2"
Background="#AA000000"
IsVisible="{Binding IsLoading}"
CornerRadius="12"/>
<TextBlock Grid.Row="1"
x:CompileBindings="False"
Text="{Binding Progress.Title}"
TextAlignment="Center"
IsVisible="{Binding IsLoading}"
VerticalAlignment="Center"/>
<!-- Progress ring -->
<controls:ProgressRing
Grid.Row="0"
@ -407,7 +439,7 @@
</controls:UserControlBase.Resources>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,*" Margin="4, 0"
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,*" Margin="4, 0"
x:Name="ParentGrid">
<!-- Top settings bar -->
<StackPanel Orientation="Horizontal">
@ -463,6 +495,36 @@
HorizontalContentAlignment="Right"
DefaultLabelPosition="Right">
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarElementContainer>
<SplitButton
Classes="transparent-full"
HorizontalAlignment="Left"
FontFamily="{DynamicResource ContentControlThemeFontFamily}"
FontWeight="Normal"
FontSize="12"
Command="{Binding FindConnectedMetadataCommand}"
Padding="8">
<SplitButton.Content>
<Grid ColumnDefinitions="Auto, Auto">
<ui:SymbolIcon Grid.Column="0"
Symbol="CloudDownload"
FontSize="18"/>
<TextBlock Grid.Column="1"
Text="{x:Static lang:Resources.Label_FindConnectedMetadata}"
Margin="8,0,0,0"
VerticalAlignment="Center"/>
</Grid>
</SplitButton.Content>
<SplitButton.Flyout>
<ui:FAMenuFlyout
Placement="Bottom">
<ui:MenuFlyoutItem Text="{x:Static lang:Resources.Action_UpdateExistingMetadata}"
Command="{Binding UpdateExistingMetadataCommand}"/>
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</ui:CommandBarElementContainer>
<ui:CommandBarButton
IconSource="OpenFolder"
VerticalAlignment="Center"
@ -494,6 +556,19 @@
Title="{x:Static lang:Resources.TeachingTip_MoreCheckpointCategories}"
PreferredPlacement="Bottom"
IsOpen="{Binding IsCategoryTipOpen}" />
<!-- Metadata scan progress -->
<StackPanel Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
IsVisible="{Binding !!Progress.Total}"
Orientation="Vertical">
<TextBlock Text="{Binding Progress.Title}"
TextAlignment="Center"
Margin="0,0,0,4"/>
<ProgressBar Value="{Binding Progress.Percentage}"
Margin="16,4,16,16"/>
</StackPanel>
<StackPanel
IsVisible="False"
@ -519,7 +594,7 @@
<ScrollViewer
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
Grid.Row="2"
x:Name="MainScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
@ -546,7 +621,7 @@
<!-- Overlay for draggable file panels -->
<Panel Name="OverlayPanel"
Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" />
Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" />
</Grid>
</controls:UserControlBase>

2
StabilityMatrix.Avalonia/Views/Dialogs/InferenceConnectionHelpDialog.axaml

@ -30,7 +30,7 @@
IsOpen="{Binding IsInstallMode}"
IsVisible="{Binding IsInstallMode}"
Margin="0,4"
Title="Would you like to be directed to install ComfyUI now?"/>
Title="Would you like to install ComfyUI now?"/>
<ui:InfoBar
Severity="Informational"

151
StabilityMatrix.Avalonia/Views/HuggingFacePage.axaml

@ -19,62 +19,70 @@
x:DataType="vm:HuggingFacePageViewModel"
Focusable="True"
mc:Ignorable="d">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="Auto, *" Margin="8">
<TextBlock VerticalAlignment="Center"
IsVisible="{Binding !!NumSelected}"
Margin="8,0,0,0">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="Auto, *" Margin="8" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="Auto, *" Background="Red" HorizontalAlignment="Stretch">
<TextBlock
Text="Hugging Face Model Import"
FontSize="24"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
HorizontalAlignment="Right"
IsVisible="{Binding !NumSelected}"
Margin="8,4,0,0">
<Run Text="{Binding NumSelected}" />
<Run Text="models selected" />
</TextBlock>
<ui:CommandBar
Grid.Row="0"
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
HorizontalAlignment="Right"
VerticalContentAlignment="Center"
DefaultLabelPosition="Right">
</Grid>
<ui:CommandBar
Grid.Row="0"
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
HorizontalAlignment="Right"
VerticalContentAlignment="Center"
DefaultLabelPosition="Right">
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarButton
IsEnabled="{Binding !!NumSelected}"
IconSource="Download"
Foreground="{DynamicResource AccentButtonBackground}"
Command="{Binding ImportSelected}"
Label="{x:Static lang:Resources.Action_Import}" />
<ui:CommandBarSeparator />
<ui:CommandBarButton
Command="{Binding SelectAll}"
IconSource="SelectAll"
Label="{x:Static lang:Resources.Action_SelectAll}" />
<ui:CommandBarButton
IconSource="ClearSelection"
Command="{Binding ClearSelection}"
IsEnabled="{Binding !!NumSelected}"
Label="{x:Static lang:Resources.Action_ClearSelection}" />
</ui:CommandBar.PrimaryCommands>
</ui:CommandBar>
<ui:CommandBar.PrimaryCommands>
<ui:CommandBarButton
IsEnabled="{Binding !!NumSelected}"
IconSource="Download"
Foreground="{DynamicResource AccentButtonBackground}"
Command="{Binding ImportSelected}"
Label="Import Selected" />
<ui:CommandBarSeparator />
<ui:CommandBarButton
Command="{Binding SelectAll}"
IconSource="SelectAll"
Label="{x:Static lang:Resources.Action_SelectAll}" />
<ui:CommandBarButton
IconSource="ClearSelection"
Command="{Binding ClearSelection}"
IsEnabled="{Binding !!NumSelected}"
Label="{x:Static lang:Resources.Action_ClearSelection}" />
</ui:CommandBar.PrimaryCommands>
</ui:CommandBar>
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
Orientation="Vertical"
Margin="0,8"
VerticalAlignment="Bottom">
<TextBlock Text="{Binding DownloadPercentText}"
TextAlignment="Center"
IsVisible="{Binding !!TotalProgress.Total}"
Margin="0,4"
HorizontalAlignment="Stretch" />
<ProgressBar Value="{Binding TotalProgress.Current}"
Maximum="{Binding TotalProgress.Total}"
IsVisible="{Binding !!TotalProgress.Total}"
IsIndeterminate="False" />
</StackPanel>
<ItemsRepeater Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
ItemsSource="{Binding Categories}">
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
Orientation="Vertical"
Margin="0,8"
VerticalAlignment="Bottom">
<TextBlock Text="{Binding DownloadPercentText}"
TextAlignment="Center"
IsVisible="{Binding !!TotalProgress.Total}"
Margin="0,4"
HorizontalAlignment="Stretch" />
<ProgressBar Value="{Binding TotalProgress.Current}"
Maximum="{Binding TotalProgress.Total}"
IsVisible="{Binding !!TotalProgress.Total}"
IsIndeterminate="False" />
</StackPanel>
<ScrollViewer Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2">
<ItemsRepeater ItemsSource="{Binding Categories}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Vertical" />
</ItemsRepeater.Layout>
@ -139,9 +147,10 @@
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Command="{x:Static helpers:IOCommands.OpenUrlCommand}"
CommandParameter="{Binding RepoUrl}"
Header="{x:Static lang:Resources.Action_OpenOnHuggingFace}">
<MenuItem
Command="{x:Static helpers:IOCommands.OpenUrlCommand}"
CommandParameter="{Binding RepoUrl}"
Header="{x:Static lang:Resources.Action_OpenOnHuggingFace}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Open" />
</MenuItem.Icon>
@ -165,20 +174,20 @@
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
<Expander Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="3"
Header="Manual Model Download">
<Grid RowDefinitions="Auto, Auto" ColumnDefinitions="*, Auto">
<TextBox Grid.Row="1" Grid.Column="0"
Watermark="Enter HuggingFace repo URL"
Margin="0,8" />
<Button Grid.Row="1" Grid.Column="1"
Margin="8,8,0,8"
Classes="accent"
Content="Import" />
</Grid>
</Expander>
</Grid>
</ScrollViewer>
</ScrollViewer>
<Expander Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="3"
Header="Manual Model Download">
<Grid RowDefinitions="Auto, Auto" ColumnDefinitions="*, Auto">
<TextBox Grid.Row="1" Grid.Column="0"
Watermark="Enter HuggingFace repo URL"
Margin="0,8" />
<Button Grid.Row="1" Grid.Column="1"
Margin="8,8,0,8"
Classes="accent"
Content="Import" />
</Grid>
</Expander>
</Grid>
</controls:UserControlBase>

2
StabilityMatrix.Core/Helper/ModelFinder.cs

@ -62,7 +62,7 @@ public class ModelFinder
var version = model.ModelVersions!.First(version => version.Id == versionResponse.Id);
var file = versionResponse.Files.First(
file => file.Hashes.BLAKE3?.ToLowerInvariant() == hashBlake3
file => file.Hashes.BLAKE3?.ToLowerInvariant() == hashBlake3.ToLowerInvariant()
);
return new ModelSearchResult(model, version, file);

4
StabilityMatrix.Core/Models/ConnectedModelInfo.cs

@ -23,6 +23,7 @@ public class ConnectedModelInfo
public DateTimeOffset ImportedAt { get; set; }
public CivitFileHashes Hashes { get; set; }
public string[]? TrainedWords { get; set; }
public CivitModelStats Stats { get; set; }
// User settings
public string? UserTitle { get; set; }
@ -39,7 +40,7 @@ public class ConnectedModelInfo
{
ModelId = civitModel.Id;
ModelName = civitModel.Name;
ModelDescription = civitModel.Description;
ModelDescription = civitModel.Description ?? string.Empty;
Nsfw = civitModel.Nsfw;
Tags = civitModel.Tags;
ModelType = civitModel.Type;
@ -51,6 +52,7 @@ public class ConnectedModelInfo
FileMetadata = civitFile.Metadata;
Hashes = civitFile.Hashes;
TrainedWords = civitModelVersion.TrainedWords;
Stats = civitModel.Stats;
}
public static ConnectedModelInfo? FromJson(string json)

6
StabilityMatrix.Core/Models/Database/LocalModelFile.cs

@ -86,8 +86,8 @@ public class LocalModelFile
}
public static readonly HashSet<string> SupportedCheckpointExtensions =
new() { ".safetensors", ".pt", ".ckpt", ".pth", ".bin" };
[".safetensors", ".pt", ".ckpt", ".pth", ".bin"];
public static readonly HashSet<string> SupportedImageExtensions =
new() { ".png", ".jpg", ".jpeg" };
public static readonly HashSet<string> SupportedMetadataExtensions = new() { ".json" };
[".png", ".jpg", ".jpeg", ".gif"];
public static readonly HashSet<string> SupportedMetadataExtensions = [".json"];
}

24
StabilityMatrix.Core/Services/IMetadataImportService.cs

@ -0,0 +1,24 @@
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Core.Services;
public interface IMetadataImportService
{
Task ScanDirectoryForMissingInfo(
DirectoryPath directory,
IProgress<ProgressReport>? progress = null
);
Task<ConnectedModelInfo?> GetMetadataForFile(
FilePath filePath,
IProgress<ProgressReport>? progress = null,
bool forceReimport = false
);
Task UpdateExistingMetadata(
DirectoryPath directory,
IProgress<ProgressReport>? progress = null
);
}

308
StabilityMatrix.Core/Services/MetadataImportService.cs

@ -0,0 +1,308 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Core.Services;
[Transient(typeof(IMetadataImportService))]
public class MetadataImportService(
ILogger<MetadataImportService> logger,
IDownloadService downloadService,
ModelFinder modelFinder
) : IMetadataImportService
{
public async Task ScanDirectoryForMissingInfo(
DirectoryPath directory,
IProgress<ProgressReport>? progress = null
)
{
progress?.Report(new ProgressReport(-1f, "Scanning directory...", isIndeterminate: true));
var checkpointsWithoutMetadata = directory
.EnumerateFiles(searchOption: SearchOption.AllDirectories)
.Where(FileHasNoCmInfo)
.ToList();
var scanned = 0;
var success = 0;
foreach (var checkpointFilePath in checkpointsWithoutMetadata)
{
if (scanned == 0)
{
progress?.Report(
new ProgressReport(
current: scanned,
total: checkpointsWithoutMetadata.Count,
$"Scanning directory..."
)
);
}
else
{
progress?.Report(
new ProgressReport(
current: scanned,
total: checkpointsWithoutMetadata.Count,
$"{success} files imported successfully"
)
);
}
var fileNameWithoutExtension = checkpointFilePath.NameWithoutExtension;
var cmInfoPath = checkpointFilePath.Directory?.JoinFile(
$"{fileNameWithoutExtension}.cm-info.json"
);
var cmInfoExists = File.Exists(cmInfoPath);
if (cmInfoExists)
continue;
var hashProgress = new Progress<ProgressReport>(report =>
{
progress?.Report(
new ProgressReport(
current: report.Current ?? 0,
total: report.Total ?? 0,
$"Scanning file {scanned}/{checkpointsWithoutMetadata.Count} ... {report.Percentage}%"
)
);
});
var blake3 = await GetBlake3Hash(cmInfoPath, checkpointFilePath, hashProgress)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(blake3))
{
logger.LogWarning($"Blake3 hash was null for {checkpointFilePath}");
scanned++;
continue;
}
var modelInfo = await modelFinder.RemoteFindModel(blake3).ConfigureAwait(false);
if (modelInfo == null)
{
logger.LogWarning($"Could not find model for {blake3}");
scanned++;
continue;
}
var (model, modelVersion, modelFile) = modelInfo.Value;
var updatedCmInfo = new ConnectedModelInfo(
model,
modelVersion,
modelFile,
DateTimeOffset.UtcNow
);
await updatedCmInfo
.SaveJsonToDirectory(checkpointFilePath.Directory, fileNameWithoutExtension)
.ConfigureAwait(false);
var image = modelVersion.Images?.FirstOrDefault(
img => LocalModelFile.SupportedImageExtensions.Contains(Path.GetExtension(img.Url))
);
if (image == null)
{
scanned++;
success++;
continue;
}
await DownloadImage(image, checkpointFilePath, progress).ConfigureAwait(false);
scanned++;
success++;
}
progress?.Report(
new ProgressReport(
current: scanned,
total: checkpointsWithoutMetadata.Count,
$"Metadata found for {success}/{checkpointsWithoutMetadata.Count} files"
)
);
}
private static bool FileHasNoCmInfo(FilePath file)
{
return LocalModelFile.SupportedCheckpointExtensions.Contains(file.Extension)
&& !File.Exists(file.Directory?.JoinFile($"{file.NameWithoutExtension}.cm-info.json"));
}
public async Task UpdateExistingMetadata(
DirectoryPath directory,
IProgress<ProgressReport>? progress = null
)
{
progress?.Report(new ProgressReport(-1f, "Scanning directory...", isIndeterminate: true));
var cmInfoList = new Dictionary<FilePath, ConnectedModelInfo>();
foreach (
var cmInfoPath in directory.EnumerateFiles(
"*.cm-info.json",
SearchOption.AllDirectories
)
)
{
var cmInfo = JsonSerializer.Deserialize<ConnectedModelInfo>(
await cmInfoPath.ReadAllTextAsync().ConfigureAwait(false)
);
if (cmInfo == null)
continue;
cmInfoList.Add(cmInfoPath, cmInfo);
}
var success = 1;
foreach (var (filePath, cmInfoValue) in cmInfoList)
{
progress?.Report(
new ProgressReport(
current: success,
total: cmInfoList.Count,
$"Updating metadata {success}/{cmInfoList.Count}"
)
);
var hash = cmInfoValue.Hashes.BLAKE3;
if (string.IsNullOrWhiteSpace(hash))
continue;
var modelInfo = await modelFinder.RemoteFindModel(hash).ConfigureAwait(false);
if (modelInfo == null)
{
logger.LogWarning($"Could not find model for {hash}");
continue;
}
var (model, modelVersion, modelFile) = modelInfo.Value;
var updatedCmInfo = new ConnectedModelInfo(
model,
modelVersion,
modelFile,
DateTimeOffset.UtcNow
);
var nameWithoutCmInfo = filePath.NameWithoutExtension.Replace(".cm-info", string.Empty);
await updatedCmInfo
.SaveJsonToDirectory(filePath.Directory, nameWithoutCmInfo)
.ConfigureAwait(false);
var image = modelVersion.Images?.FirstOrDefault(
img => LocalModelFile.SupportedImageExtensions.Contains(Path.GetExtension(img.Url))
);
if (image == null)
continue;
await DownloadImage(image, filePath, progress).ConfigureAwait(false);
success++;
}
}
public async Task<ConnectedModelInfo?> GetMetadataForFile(
FilePath filePath,
IProgress<ProgressReport>? progress = null,
bool forceReimport = false
)
{
progress?.Report(new ProgressReport(-1f, "Getting metadata...", isIndeterminate: true));
var fileNameWithoutExtension = filePath.NameWithoutExtension;
var cmInfoPath = filePath.Directory?.JoinFile($"{fileNameWithoutExtension}.cm-info.json");
var cmInfoExists = File.Exists(cmInfoPath);
if (cmInfoExists && !forceReimport)
return null;
var hashProgress = new Progress<ProgressReport>(report =>
{
progress?.Report(
new ProgressReport(
current: report.Current ?? 0,
total: report.Total ?? 0,
$"Getting metadata for {filePath} ... {report.Percentage}%"
)
);
});
var blake3 = await GetBlake3Hash(cmInfoPath, filePath, hashProgress).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(blake3))
{
logger.LogWarning($"Blake3 hash was null for {filePath}");
return null;
}
var modelInfo = await modelFinder.RemoteFindModel(blake3).ConfigureAwait(false);
if (modelInfo == null)
{
logger.LogWarning($"Could not find model for {blake3}");
return null;
}
var (model, modelVersion, modelFile) = modelInfo.Value;
var updatedCmInfo = new ConnectedModelInfo(
model,
modelVersion,
modelFile,
DateTimeOffset.UtcNow
);
await updatedCmInfo
.SaveJsonToDirectory(filePath.Directory, fileNameWithoutExtension)
.ConfigureAwait(false);
var image = modelVersion.Images?.FirstOrDefault(
img => LocalModelFile.SupportedImageExtensions.Contains(Path.GetExtension(img.Url))
);
if (image == null)
return updatedCmInfo;
await DownloadImage(image, filePath, progress).ConfigureAwait(false);
return updatedCmInfo;
}
private static async Task<string?> GetBlake3Hash(
FilePath? cmInfoPath,
FilePath checkpointFilePath,
IProgress<ProgressReport> hashProgress
)
{
if (string.IsNullOrWhiteSpace(cmInfoPath?.ToString()) || !File.Exists(cmInfoPath))
{
return await FileHash
.GetBlake3Async(checkpointFilePath, hashProgress)
.ConfigureAwait(false);
}
var cmInfo = JsonSerializer.Deserialize<ConnectedModelInfo>(
await cmInfoPath.ReadAllTextAsync().ConfigureAwait(false)
);
return cmInfo?.Hashes.BLAKE3;
}
private Task DownloadImage(
CivitImage image,
FilePath modelFilePath,
IProgress<ProgressReport>? progress
)
{
var imageExt = Path.GetExtension(image.Url).TrimStart('.');
var nameWithoutCmInfo = modelFilePath.NameWithoutExtension.Replace(
".cm-info",
string.Empty
);
var imageDownloadPath = Path.GetFullPath(
Path.Combine(modelFilePath.Directory, $"{nameWithoutCmInfo}.preview.{imageExt}")
);
return downloadService.DownloadToFileAsync(image.Url, imageDownloadPath, progress);
}
}
Loading…
Cancel
Save