Browse Source

Merge branch 'dev' into downmerge

pull/438/head
JT 11 months ago committed by GitHub
parent
commit
e5aedc1b18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 196
      .github/workflows/release.yml
  2. 18
      Avalonia.Gif/Avalonia.Gif.csproj
  3. 10
      Avalonia.Gif/BgWorkerCommand.cs
  4. 12
      Avalonia.Gif/BgWorkerState.cs
  5. 10
      Avalonia.Gif/Decoding/BlockTypes.cs
  6. 8
      Avalonia.Gif/Decoding/ExtensionType.cs
  7. 10
      Avalonia.Gif/Decoding/FrameDisposal.cs
  8. 36
      Avalonia.Gif/Decoding/GifColor.cs
  9. 653
      Avalonia.Gif/Decoding/GifDecoder.cs
  10. 20
      Avalonia.Gif/Decoding/GifFrame.cs
  11. 19
      Avalonia.Gif/Decoding/GifHeader.cs
  12. 43
      Avalonia.Gif/Decoding/GifRect.cs
  13. 8
      Avalonia.Gif/Decoding/GifRepeatBehavior.cs
  14. 23
      Avalonia.Gif/Decoding/InvalidGifStreamException.cs
  15. 23
      Avalonia.Gif/Decoding/LzwDecompressionException.cs
  16. 81
      Avalonia.Gif/Extensions/StreamExtensions.cs
  17. 297
      Avalonia.Gif/GifImage.cs
  18. 147
      Avalonia.Gif/GifInstance.cs
  19. 15
      Avalonia.Gif/IGifInstance.cs
  20. 20
      Avalonia.Gif/InvalidGifStreamException.cs
  21. 180
      Avalonia.Gif/WebpInstance.cs
  22. 8
      Build/AppEntitlements.entitlements
  23. 12
      Build/EmbeddedEntitlements.entitlements
  24. 26
      Build/build_macos_app.sh
  25. 62
      Build/codesign_embedded_macos.sh
  26. 37
      Build/codesign_macos.sh
  27. 32
      Build/notarize_macos.sh
  28. 38
      CHANGELOG.md
  29. 6
      README.md
  30. 1
      StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
  31. 4
      StabilityMatrix.Avalonia/App.axaml
  32. 148
      StabilityMatrix.Avalonia/App.axaml.cs
  33. 28
      StabilityMatrix.Avalonia/Assets.cs
  34. BIN
      StabilityMatrix.Avalonia/Assets/AppIcon.icns
  35. 21
      StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml
  36. 7
      StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml.cs
  37. 41
      StabilityMatrix.Avalonia/Controls/BetterComboBox.cs
  38. 46
      StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs
  39. 157
      StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml
  40. 122
      StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml
  41. 7
      StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs
  42. 98
      StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml
  43. 7
      StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs
  44. 137
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  45. 17
      StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs
  46. 47
      StabilityMatrix.Avalonia/DialogHelper.cs
  47. 35
      StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs
  48. 17
      StabilityMatrix.Avalonia/Helpers/UriHandler.cs
  49. 90
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  50. 30
      StabilityMatrix.Avalonia/Languages/Resources.resx
  51. 33
      StabilityMatrix.Avalonia/Models/CommandItem.cs
  52. 11
      StabilityMatrix.Avalonia/Models/ITemplateKey.cs
  53. 19
      StabilityMatrix.Avalonia/Models/ImageSource.cs
  54. 8
      StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs
  55. 15
      StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs
  56. 11
      StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs
  57. 2
      StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs
  58. 4
      StabilityMatrix.Avalonia/Models/InferenceProjectType.cs
  59. 10
      StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs
  60. 7
      StabilityMatrix.Avalonia/Services/INavigationService.cs
  61. 48
      StabilityMatrix.Avalonia/Services/NavigationService.cs
  62. 45
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  63. 254
      StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml
  64. 42
      StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml
  65. 157
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
  66. 54
      StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
  67. 40
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
  68. 39
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
  69. 2
      StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs
  70. 17
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs
  71. 64
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs
  72. 38
      StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs
  73. 15
      StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs
  74. 104
      StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
  75. 23
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  76. 13
      StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs
  77. 17
      StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs
  78. 245
      StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs
  79. 14
      StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs
  80. 77
      StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs
  81. 6
      StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs
  82. 31
      StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs
  83. 9
      StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs
  84. 121
      StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs
  85. 66
      StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs
  86. 35
      StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs
  87. 104
      StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs
  88. 94
      StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs
  89. 107
      StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs
  90. 11
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  91. 67
      StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs
  92. 22
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  93. 32
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  94. 143
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs
  95. 254
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs
  96. 72
      StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
  97. 139
      StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
  98. 17
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  99. 7
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs
  100. 1
      StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs
  101. Some files were not shown because too many files have changed in this diff Show More

196
.github/workflows/release.yml

@ -43,6 +43,10 @@ on:
- stable
- preview
- development
test-release-artifacts:
type: boolean
description: "[Debug] Test release artifacts?"
default: false
jobs:
release-linux:
@ -172,10 +176,121 @@ jobs:
name: StabilityMatrix-${{ env.platform-id }}
path: ./out/${{ env.out-name }}
release-macos:
name: Release (macos-arm64)
env:
platform-id: osx-arm64
app-name: "Stability Matrix.app"
out-name: "StabilityMatrix-macos-arm64.dmg"
runs-on: macos-13
steps:
- uses: actions/checkout@v3
- uses: olegtarasov/get-tag@v2.1.2
if: github.event_name == 'release'
id: tag_name
with:
tagRegex: "v(.*)"
- name: Set Version from Tag
if: github.event_name == 'release'
run: |
echo "Using tag ${{ env.GIT_TAG_NAME }}"
echo "RELEASE_VERSION=${{ env.GIT_TAG_NAME }}" >> $GITHUB_ENV
- name: Set Version from manual input
if: github.event_name == 'workflow_dispatch'
run: |
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
- name: Set up .NET 8
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore -p:PublishReadyToRun=true
- name: Check Version
run: echo $RELEASE_VERSION
- name: .NET Msbuild (App)
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: >
dotnet msbuild ./StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
-t:BundleApp -p:UseAppHost=true -p:SelfContained=true
-p:Configuration=Release -p:RuntimeIdentifier=${{ env.platform-id }}
-p:Version=$RELEASE_VERSION
-p:PublishDir=out
-p:PublishReadyToRun=true
-p:CFBundleShortVersionString=$RELEASE_VERSION
-p:CFBundleName="Stability Matrix"
-p:CFBundleDisplayName="Stability Matrix"
-p:CFBundleVersion=$RELEASE_VERSION
-p:SentryOrg=${{ secrets.SENTRY_ORG }} -p:SentryProject=${{ secrets.SENTRY_PROJECT }}
-p:SentryUploadSymbols=true -p:SentryUploadSources=true
- name: Post Build (App)
run: mkdir -p signing && mv "./StabilityMatrix.Avalonia/out/Stability Matrix.app" "./signing/${{ env.app-name }}"
- name: Codesign app bundle
env:
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }}
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
run: ./Build/codesign_macos.sh "./signing/${{ env.app-name }}"
- name: Notarize app bundle
env:
MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}
MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
run: ./Build/notarize_macos.sh "./signing/${{ env.app-name }}"
- name: Zip Artifact (App)
working-directory: signing
run: zip -r -y "../StabilityMatrix-${{ env.platform-id }}-app.zip" "${{ env.app-name }}"
- name: Upload Artifact (App)
uses: actions/upload-artifact@v2
with:
name: StabilityMatrix-${{ env.platform-id }}-app
path: StabilityMatrix-${{ env.platform-id }}-app.zip
- uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Install dependencies for dmg creation
run: >
npm install --global create-dmg
brew install graphicsmagick imagemagick
- name: Create dmg
working-directory: signing
run: >
create-dmg "${{ env.app-name }}" --overwrite --identity "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}"
- name: Rename dmg
working-directory: signing
run: mv "$(find . -type f -name "*.dmg")" "${{ env.out-name }}"
- name: Zip Artifact (dmg)
working-directory: signing
run: zip -r -y "../StabilityMatrix-${{ env.platform-id }}-dmg.zip" "${{ env.out-name }}"
- name: Upload Artifact (dmg)
uses: actions/upload-artifact@v2
with:
name: StabilityMatrix-${{ env.platform-id }}-dmg
path: StabilityMatrix-${{ env.platform-id }}-dmg.zip
publish-release:
name: Publish GitHub Release
needs: [ release-linux, release-windows ]
needs: [ release-linux, release-windows, release-macos ]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.github-release == 'true' }}
runs-on: ubuntu-latest
steps:
@ -194,11 +309,12 @@ jobs:
- name: Download Artifacts
uses: actions/download-artifact@v3
# Zip each build
# Zip each build (except macos which is already dmg)
- name: Zip Artifacts
run: |
cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD
cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD
unzip "StabilityMatrix-osx-arm64-dmg/StabilityMatrix-osx-arm64-dmg.zip"
- name: Create Github Release
id: create_release
@ -209,15 +325,75 @@ jobs:
files: |
StabilityMatrix-win-x64.zip
StabilityMatrix-linux-x64.zip
StabilityMatrix-macos-arm64.dmg
fail_on_unmatched_files: true
tag_name: v${{ github.event.inputs.version }}
body: ${{ steps.release_notes.outputs.release_notes }}
draft: ${{ github.event.inputs.github-release-draft == 'true' }}
prerelease: ${{ github.event.inputs.github-release-prerelease == 'true' }}
test-artifacts:
name: Test Release Artifacts
needs: [ release-linux, release-windows, release-macos ]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.test-release-artifacts == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Extract Release Notes
id: release_notes
run: |
RELEASE_NOTES="$(awk -v version="${{ github.event.inputs.version }}" '/## v/{if(p) exit; if($0 ~ version) p=1}; p' CHANGELOG.md)"
RELEASE_NOTES="${RELEASE_NOTES//'%'/'%25'}"
RELEASE_NOTES="${RELEASE_NOTES//$'\n'/'%0A'}"
RELEASE_NOTES="${RELEASE_NOTES//$'\r'/'%0D'}"
echo "::set-output name=release_notes::$RELEASE_NOTES"
echo "Release Notes:"
echo "$RELEASE_NOTES"
# Downloads all previous artifacts to the current working directory
- name: Download Artifacts
uses: actions/download-artifact@v3
# Zip each build (except macos which is already dmg)
- name: Zip Artifacts
run: |
cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD
cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD
unzip "StabilityMatrix-osx-arm64-dmg/StabilityMatrix-osx-arm64-dmg.zip"
# Check that the zips and CHANGELOG.md are in the current working directory
- name: Check files
run: |
if [ ! -f StabilityMatrix-win-x64.zip ]; then
echo "StabilityMatrix-win-x64.zip not found"
exit 1
else
echo "StabilityMatrix-win-x64.zip found"
sha256sum StabilityMatrix-win-x64.zip
fi
if [ ! -f StabilityMatrix-linux-x64.zip ]; then
echo "StabilityMatrix-linux-x64.zip not found"
exit 1
else
echo "StabilityMatrix-linux-x64.zip found"
sha256sum StabilityMatrix-linux-x64.zip
fi
if [ ! -f StabilityMatrix-macos-arm64.dmg ]; then
echo "StabilityMatrix-macos-arm64.dmg not found"
exit 1
else
echo "StabilityMatrix-macos-arm64.dmg found"
sha256sum StabilityMatrix-macos-arm64.dmg
fi
if [ ! -f CHANGELOG.md ]; then
echo "CHANGELOG.md not found"
exit 1
fi
publish-auto-update-github:
name: Publish Auto-Update Release (GitHub)
needs: [ release-linux, release-windows ]
needs: [ release-linux, release-windows, release-macos ]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auto-update-release == 'true' && github.event.inputs.auto-update-release-mode == 'github url' }}
runs-on: ubuntu-latest
steps:
@ -233,7 +409,7 @@ jobs:
python-version: '3.11'
- name: Install Python Dependencies
run: pip install stability-matrix-tools>=0.2.18 --upgrade
run: pip install stability-matrix-tools>=0.3.0 --upgrade
- name: Publish Auto-Update Release
env:
@ -246,7 +422,7 @@ jobs:
publish-auto-update-b2:
name: Publish Auto-Update Release (B2)
needs: [ release-linux, release-windows ]
needs: [ release-linux, release-windows, release-macos ]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auto-update-release == 'true' && github.event.inputs.auto-update-release-mode == 'upload to b2' }}
runs-on: ubuntu-latest
steps:
@ -261,18 +437,19 @@ jobs:
- name: Download Artifacts
uses: actions/download-artifact@v3
# Zip each build
# Zip each build (except macos which is already dmg)
- name: Zip Artifacts
run: |
cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD
cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD
unzip "StabilityMatrix-osx-arm64-dmg/StabilityMatrix-osx-arm64-dmg.zip"
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Python Dependencies
run: pip install stability-matrix-tools>=0.2.18 --upgrade
run: pip install stability-matrix-tools>=0.3.0 --upgrade
# Check that the zips and CHANGELOG.md are in the current working directory
- name: Check files
@ -285,6 +462,10 @@ jobs:
echo "StabilityMatrix-linux-x64.zip not found"
exit 1
fi
if [ ! -f StabilityMatrix-macos-arm64.dmg ]; then
echo "StabilityMatrix-macos-arm64.dmg not found"
exit 1
fi
if [ ! -f CHANGELOG.md ]; then
echo "CHANGELOG.md not found"
exit 1
@ -303,4 +484,5 @@ jobs:
--changelog CHANGELOG.md
--win-x64 StabilityMatrix-win-x64.zip
--linux-x64 StabilityMatrix-linux-x64.zip
--macos-arm64 StabilityMatrix-macos-arm64.dmg
-y

18
Avalonia.Gif/Avalonia.Gif.csproj

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="SkiaSharp" Version="2.88.6" />
<PackageReference Include="DotNet.Bundle" Version="0.9.13" />
</ItemGroup>
</Project>

10
Avalonia.Gif/BgWorkerCommand.cs

@ -0,0 +1,10 @@
namespace Avalonia.Gif
{
internal enum BgWorkerCommand
{
Null,
Play,
Pause,
Dispose
}
}

12
Avalonia.Gif/BgWorkerState.cs

@ -0,0 +1,12 @@
namespace Avalonia.Gif
{
internal enum BgWorkerState
{
Null,
Start,
Running,
Paused,
Complete,
Dispose
}
}

10
Avalonia.Gif/Decoding/BlockTypes.cs

@ -0,0 +1,10 @@
namespace Avalonia.Gif.Decoding
{
internal enum BlockTypes
{
Empty = 0,
Extension = 0x21,
ImageDescriptor = 0x2C,
Trailer = 0x3B,
}
}

8
Avalonia.Gif/Decoding/ExtensionType.cs

@ -0,0 +1,8 @@
namespace Avalonia.Gif.Decoding
{
internal enum ExtensionType
{
GraphicsControl = 0xF9,
Application = 0xFF
}
}

10
Avalonia.Gif/Decoding/FrameDisposal.cs

@ -0,0 +1,10 @@
namespace Avalonia.Gif.Decoding
{
public enum FrameDisposal
{
Unknown = 0,
Leave = 1,
Background = 2,
Restore = 3
}
}

36
Avalonia.Gif/Decoding/GifColor.cs

@ -0,0 +1,36 @@
using System.Runtime.InteropServices;
namespace Avalonia.Gif
{
[StructLayout(LayoutKind.Explicit)]
public readonly struct GifColor
{
[FieldOffset(3)]
public readonly byte A;
[FieldOffset(2)]
public readonly byte R;
[FieldOffset(1)]
public readonly byte G;
[FieldOffset(0)]
public readonly byte B;
/// <summary>
/// A struct that represents a ARGB color and is aligned as
/// a BGRA bytefield in memory.
/// </summary>
/// <param name="r">Red</param>
/// <param name="g">Green</param>
/// <param name="b">Blue</param>
/// <param name="a">Alpha</param>
public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue)
{
A = a;
R = r;
G = g;
B = b;
}
}
}

653
Avalonia.Gif/Decoding/GifDecoder.cs

@ -0,0 +1,653 @@
// This source file's Lempel-Ziv-Welch algorithm is derived from Chromium's Android GifPlayer
// as seen here (https://github.com/chromium/chromium/blob/master/third_party/gif_player/src/jp/tomorrowkey/android/gifplayer)
// Licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
// Copyright (C) 2015 The Gifplayer Authors. All Rights Reserved.
// The rest of the source file is licensed under MIT License.
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using Avalonia;
using Avalonia.Media.Imaging;
using static Avalonia.Gif.Extensions.StreamExtensions;
namespace Avalonia.Gif.Decoding
{
public sealed class GifDecoder : IDisposable
{
private static readonly ReadOnlyMemory<byte> G87AMagic = "GIF87a"u8.ToArray().AsMemory();
private static readonly ReadOnlyMemory<byte> G89AMagic = "GIF89a"u8.ToArray().AsMemory();
private static readonly ReadOnlyMemory<byte> NetscapeMagic = "NETSCAPE2.0"u8.ToArray().AsMemory();
private static readonly TimeSpan FrameDelayThreshold = TimeSpan.FromMilliseconds(10);
private static readonly TimeSpan FrameDelayDefault = TimeSpan.FromMilliseconds(100);
private static readonly GifColor TransparentColor = new(0, 0, 0, 0);
private static readonly int MaxTempBuf = 768;
private static readonly int MaxStackSize = 4096;
private static readonly int MaxBits = 4097;
private readonly Stream _fileStream;
private readonly CancellationToken _currentCtsToken;
private readonly bool _hasFrameBackups;
private int _gctSize,
_bgIndex,
_prevFrame = -1,
_backupFrame = -1;
private bool _gctUsed;
private GifRect _gifDimensions;
// private ulong _globalColorTable;
private readonly int _backBufferBytes;
private GifColor[] _bitmapBackBuffer;
private short[] _prefixBuf;
private byte[] _suffixBuf;
private byte[] _pixelStack;
private byte[] _indexBuf;
private byte[] _backupFrameIndexBuf;
private volatile bool _hasNewFrame;
public GifHeader Header { get; private set; }
public readonly List<GifFrame> Frames = new();
public PixelSize Size => new PixelSize(Header.Dimensions.Width, Header.Dimensions.Height);
public GifDecoder(Stream fileStream, CancellationToken currentCtsToken)
{
_fileStream = fileStream;
_currentCtsToken = currentCtsToken;
ProcessHeaderData();
ProcessFrameData();
Header.IterationCount = Header.Iterations switch
{
-1 => new GifRepeatBehavior { Count = 1 },
0 => new GifRepeatBehavior { LoopForever = true },
> 0 => new GifRepeatBehavior { Count = Header.Iterations },
_ => Header.IterationCount
};
var pixelCount = _gifDimensions.TotalPixels;
_hasFrameBackups = Frames.Any(f => f.FrameDisposalMethod == FrameDisposal.Restore);
_bitmapBackBuffer = new GifColor[pixelCount];
_indexBuf = new byte[pixelCount];
if (_hasFrameBackups)
_backupFrameIndexBuf = new byte[pixelCount];
_prefixBuf = new short[MaxStackSize];
_suffixBuf = new byte[MaxStackSize];
_pixelStack = new byte[MaxStackSize + 1];
_backBufferBytes = pixelCount * Marshal.SizeOf(typeof(GifColor));
}
public void Dispose()
{
Frames.Clear();
_bitmapBackBuffer = null;
_prefixBuf = null;
_suffixBuf = null;
_pixelStack = null;
_indexBuf = null;
_backupFrameIndexBuf = null;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int PixCoord(int x, int y) => x + y * _gifDimensions.Width;
static readonly (int Start, int Step)[] Pass = { (0, 8), (4, 8), (2, 4), (1, 2) };
private void ClearImage()
{
Array.Fill(_bitmapBackBuffer, TransparentColor);
//ClearArea(_gifDimensions);
_prevFrame = -1;
_backupFrame = -1;
}
public void RenderFrame(int fIndex, WriteableBitmap writeableBitmap, bool forceClear = false)
{
if (_currentCtsToken.IsCancellationRequested)
return;
if (fIndex < 0 | fIndex >= Frames.Count)
return;
if (_prevFrame == fIndex)
return;
if (fIndex == 0 || forceClear || fIndex < _prevFrame)
ClearImage();
DisposePreviousFrame();
_prevFrame++;
// render intermediate frame
for (int idx = _prevFrame; idx < fIndex; ++idx)
{
var prevFrame = Frames[idx];
if (prevFrame.FrameDisposalMethod == FrameDisposal.Restore)
continue;
if (prevFrame.FrameDisposalMethod == FrameDisposal.Background)
{
ClearArea(prevFrame.Dimensions);
continue;
}
RenderFrameAt(idx, writeableBitmap);
}
RenderFrameAt(fIndex, writeableBitmap);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void RenderFrameAt(int idx, WriteableBitmap writeableBitmap)
{
var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
var curFrame = Frames[idx];
DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB);
if (_hasFrameBackups & curFrame.ShouldBackup)
{
Buffer.BlockCopy(_indexBuf, 0, _backupFrameIndexBuf, 0, curFrame.Dimensions.TotalPixels);
_backupFrame = idx;
}
DrawFrame(curFrame, _indexBuf);
_prevFrame = idx;
_hasNewFrame = true;
using var lockedBitmap = writeableBitmap.Lock();
WriteBackBufToFb(lockedBitmap.Address);
ArrayPool<byte>.Shared.Return(tmpB);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void DrawFrame(GifFrame curFrame, Memory<byte> frameIndexSpan)
{
var activeColorTable = curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header.GlobarColorTable;
var cX = curFrame.Dimensions.X;
var cY = curFrame.Dimensions.Y;
var cH = curFrame.Dimensions.Height;
var cW = curFrame.Dimensions.Width;
var tC = curFrame.TransparentColorIndex;
var hT = curFrame.HasTransparency;
if (curFrame.IsInterlaced)
{
for (var i = 0; i < 4; i++)
{
var curPass = Pass[i];
var y = curPass.Start;
while (y < cH)
{
DrawRow(y);
y += curPass.Step;
}
}
}
else
{
for (var i = 0; i < cH; i++)
DrawRow(i);
}
//for (var row = 0; row < cH; row++)
void DrawRow(int row)
{
// Get the starting point of the current row on frame's index stream.
var indexOffset = row * cW;
// Get the target backbuffer offset from the frames coords.
var targetOffset = PixCoord(cX, row + cY);
var len = _bitmapBackBuffer.Length;
for (var i = 0; i < cW; i++)
{
var indexColor = frameIndexSpan.Span[indexOffset + i];
if (activeColorTable == null || targetOffset >= len || indexColor > activeColorTable.Length)
return;
if (!(hT & indexColor == tC))
_bitmapBackBuffer[targetOffset] = activeColorTable[indexColor];
targetOffset++;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void DisposePreviousFrame()
{
if (_prevFrame == -1)
return;
var prevFrame = Frames[_prevFrame];
switch (prevFrame.FrameDisposalMethod)
{
case FrameDisposal.Background:
ClearArea(prevFrame.Dimensions);
break;
case FrameDisposal.Restore:
if (_hasFrameBackups && _backupFrame != -1)
DrawFrame(Frames[_backupFrame], _backupFrameIndexBuf);
else
ClearArea(prevFrame.Dimensions);
break;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ClearArea(GifRect area)
{
for (var y = 0; y < area.Height; y++)
{
var targetOffset = PixCoord(area.X, y + area.Y);
for (var x = 0; x < area.Width; x++)
_bitmapBackBuffer[targetOffset + x] = TransparentColor;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void DecompressFrameToIndexBuffer(GifFrame curFrame, Span<byte> indexSpan, byte[] tempBuf)
{
_fileStream.Position = curFrame.LzwStreamPosition;
var totalPixels = curFrame.Dimensions.TotalPixels;
// Initialize GIF data stream decoder.
var dataSize = curFrame.LzwMinCodeSize;
var clear = 1 << dataSize;
var endOfInformation = clear + 1;
var available = clear + 2;
var oldCode = -1;
var codeSize = dataSize + 1;
var codeMask = (1 << codeSize) - 1;
for (var code = 0; code < clear; code++)
{
_prefixBuf[code] = 0;
_suffixBuf[code] = (byte)code;
}
// Decode GIF pixel stream.
int bits,
first,
top,
pixelIndex;
var datum = bits = first = top = pixelIndex = 0;
while (pixelIndex < totalPixels)
{
var blockSize = _fileStream.ReadBlock(tempBuf);
if (blockSize == 0)
break;
var blockPos = 0;
while (blockPos < blockSize)
{
datum += tempBuf[blockPos] << bits;
blockPos++;
bits += 8;
while (bits >= codeSize)
{
// Get the next code.
var code = datum & codeMask;
datum >>= codeSize;
bits -= codeSize;
// Interpret the code
if (code == clear)
{
// Reset decoder.
codeSize = dataSize + 1;
codeMask = (1 << codeSize) - 1;
available = clear + 2;
oldCode = -1;
continue;
}
// Check for explicit end-of-stream
if (code == endOfInformation)
return;
if (oldCode == -1)
{
indexSpan[pixelIndex++] = _suffixBuf[code];
oldCode = code;
first = code;
continue;
}
var inCode = code;
if (code >= available)
{
_pixelStack[top++] = (byte)first;
code = oldCode;
if (top == MaxBits)
ThrowException();
}
while (code >= clear)
{
if (code >= MaxBits || code == _prefixBuf[code])
ThrowException();
_pixelStack[top++] = _suffixBuf[code];
code = _prefixBuf[code];
if (top == MaxBits)
ThrowException();
}
first = _suffixBuf[code];
_pixelStack[top++] = (byte)first;
// Add new code to the dictionary
if (available < MaxStackSize)
{
_prefixBuf[available] = (short)oldCode;
_suffixBuf[available] = (byte)first;
available++;
if ((available & codeMask) == 0 && available < MaxStackSize)
{
codeSize++;
codeMask += available;
}
}
oldCode = inCode;
// Drain the pixel stack.
do
{
indexSpan[pixelIndex++] = _pixelStack[--top];
} while (top > 0);
}
}
}
while (pixelIndex < totalPixels)
indexSpan[pixelIndex++] = 0; // clear missing pixels
void ThrowException() => throw new LzwDecompressionException();
}
/// <summary>
/// Directly copies the <see cref="GifColor"/> struct array to a bitmap IntPtr.
/// </summary>
private void WriteBackBufToFb(IntPtr targetPointer)
{
if (_currentCtsToken.IsCancellationRequested)
return;
if (!(_hasNewFrame & _bitmapBackBuffer != null))
return;
unsafe
{
fixed (void* src = &_bitmapBackBuffer[0])
Buffer.MemoryCopy(src, targetPointer.ToPointer(), (uint)_backBufferBytes, (uint)_backBufferBytes);
_hasNewFrame = false;
}
}
/// <summary>
/// Processes GIF Header.
/// </summary>
private void ProcessHeaderData()
{
var str = _fileStream;
var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
var tempBuf = tmpB.AsSpan();
var _ = str.Read(tmpB, 0, 6);
if (!tempBuf[..3].SequenceEqual(G87AMagic[..3].Span))
throw new InvalidGifStreamException("Not a GIF stream.");
if (!(tempBuf[..6].SequenceEqual(G87AMagic.Span) | tempBuf[..6].SequenceEqual(G89AMagic.Span)))
throw new InvalidGifStreamException(
"Unsupported GIF Version: " + Encoding.ASCII.GetString(tempBuf[..6].ToArray())
);
ProcessScreenDescriptor(tmpB);
Header = new GifHeader
{
Dimensions = _gifDimensions,
HasGlobalColorTable = _gctUsed,
// GlobalColorTableCacheID = _globalColorTable,
GlobarColorTable = ProcessColorTable(ref str, tmpB, _gctSize),
GlobalColorTableSize = _gctSize,
BackgroundColorIndex = _bgIndex,
HeaderSize = _fileStream.Position
};
ArrayPool<byte>.Shared.Return(tmpB);
}
/// <summary>
/// Parses colors from file stream to target color table.
/// </summary>
private static GifColor[] ProcessColorTable(ref Stream stream, byte[] rawBufSpan, int nColors)
{
var nBytes = 3 * nColors;
var target = new GifColor[nColors];
var n = stream.Read(rawBufSpan, 0, nBytes);
if (n < nBytes)
throw new InvalidOperationException("Wrong color table bytes.");
int i = 0,
j = 0;
while (i < nColors)
{
var r = rawBufSpan[j++];
var g = rawBufSpan[j++];
var b = rawBufSpan[j++];
target[i++] = new GifColor(r, g, b);
}
return target;
}
/// <summary>
/// Parses screen and other GIF descriptors.
/// </summary>
private void ProcessScreenDescriptor(byte[] tempBuf)
{
var width = _fileStream.ReadUShortS(tempBuf);
var height = _fileStream.ReadUShortS(tempBuf);
var packed = _fileStream.ReadByteS(tempBuf);
_gctUsed = (packed & 0x80) != 0;
_gctSize = 2 << (packed & 7);
_bgIndex = _fileStream.ReadByteS(tempBuf);
_gifDimensions = new GifRect(0, 0, width, height);
_fileStream.Skip(1);
}
/// <summary>
/// Parses all frame data.
/// </summary>
private void ProcessFrameData()
{
_fileStream.Position = Header.HeaderSize;
var tempBuf = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
var terminate = false;
var curFrame = 0;
Frames.Add(new GifFrame());
do
{
var blockType = (BlockTypes)_fileStream.ReadByteS(tempBuf);
switch (blockType)
{
case BlockTypes.Empty:
break;
case BlockTypes.Extension:
ProcessExtensions(ref curFrame, tempBuf);
break;
case BlockTypes.ImageDescriptor:
ProcessImageDescriptor(ref curFrame, tempBuf);
_fileStream.SkipBlocks(tempBuf);
break;
case BlockTypes.Trailer:
Frames.RemoveAt(Frames.Count - 1);
terminate = true;
break;
default:
_fileStream.SkipBlocks(tempBuf);
break;
}
// Break the loop when the stream is not valid anymore.
if (_fileStream.Position >= _fileStream.Length & terminate == false)
throw new InvalidProgramException("Reach the end of the filestream without trailer block.");
} while (!terminate);
ArrayPool<byte>.Shared.Return(tempBuf);
}
/// <summary>
/// Parses GIF Image Descriptor Block.
/// </summary>
private void ProcessImageDescriptor(ref int curFrame, byte[] tempBuf)
{
var str = _fileStream;
var currentFrame = Frames[curFrame];
// Parse frame dimensions.
var frameX = str.ReadUShortS(tempBuf);
var frameY = str.ReadUShortS(tempBuf);
var frameW = str.ReadUShortS(tempBuf);
var frameH = str.ReadUShortS(tempBuf);
frameW = (ushort)Math.Min(frameW, _gifDimensions.Width - frameX);
frameH = (ushort)Math.Min(frameH, _gifDimensions.Height - frameY);
currentFrame.Dimensions = new GifRect(frameX, frameY, frameW, frameH);
// Unpack interlace and lct info.
var packed = str.ReadByteS(tempBuf);
currentFrame.IsInterlaced = (packed & 0x40) != 0;
currentFrame.IsLocalColorTableUsed = (packed & 0x80) != 0;
currentFrame.LocalColorTableSize = (int)Math.Pow(2, (packed & 0x07) + 1);
if (currentFrame.IsLocalColorTableUsed)
currentFrame.LocalColorTable = ProcessColorTable(ref str, tempBuf, currentFrame.LocalColorTableSize);
currentFrame.LzwMinCodeSize = str.ReadByteS(tempBuf);
currentFrame.LzwStreamPosition = str.Position;
curFrame += 1;
Frames.Add(new GifFrame());
}
/// <summary>
/// Parses GIF Extension Blocks.
/// </summary>
private void ProcessExtensions(ref int curFrame, byte[] tempBuf)
{
var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf);
switch (extType)
{
case ExtensionType.GraphicsControl:
_fileStream.ReadBlock(tempBuf);
var currentFrame = Frames[curFrame];
var packed = tempBuf[0];
currentFrame.FrameDisposalMethod = (FrameDisposal)((packed & 0x1c) >> 2);
if (
currentFrame.FrameDisposalMethod != FrameDisposal.Restore
&& currentFrame.FrameDisposalMethod != FrameDisposal.Background
)
currentFrame.ShouldBackup = true;
currentFrame.HasTransparency = (packed & 1) != 0;
currentFrame.FrameDelay = TimeSpan.FromMilliseconds(SpanToShort(tempBuf.AsSpan(1)) * 10);
if (currentFrame.FrameDelay <= FrameDelayThreshold)
currentFrame.FrameDelay = FrameDelayDefault;
currentFrame.TransparentColorIndex = tempBuf[3];
break;
case ExtensionType.Application:
var blockLen = _fileStream.ReadBlock(tempBuf);
var _ = tempBuf.AsSpan(0, blockLen);
var blockHeader = tempBuf.AsSpan(0, NetscapeMagic.Length);
if (blockHeader.SequenceEqual(NetscapeMagic.Span))
{
var count = 1;
while (count > 0)
count = _fileStream.ReadBlock(tempBuf);
var iterationCount = SpanToShort(tempBuf.AsSpan(1));
Header.Iterations = iterationCount;
}
else
_fileStream.SkipBlocks(tempBuf);
break;
default:
_fileStream.SkipBlocks(tempBuf);
break;
}
}
}
}

20
Avalonia.Gif/Decoding/GifFrame.cs

@ -0,0 +1,20 @@
using System;
namespace Avalonia.Gif.Decoding
{
public class GifFrame
{
public bool HasTransparency,
IsInterlaced,
IsLocalColorTableUsed;
public byte TransparentColorIndex;
public int LzwMinCodeSize,
LocalColorTableSize;
public long LzwStreamPosition;
public TimeSpan FrameDelay;
public FrameDisposal FrameDisposalMethod;
public bool ShouldBackup;
public GifRect Dimensions;
public GifColor[] LocalColorTable;
}
}

19
Avalonia.Gif/Decoding/GifHeader.cs

@ -0,0 +1,19 @@
// Licensed under the MIT License.
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
namespace Avalonia.Gif.Decoding
{
public class GifHeader
{
public bool HasGlobalColorTable;
public int GlobalColorTableSize;
public ulong GlobalColorTableCacheId;
public int BackgroundColorIndex;
public long HeaderSize;
internal int Iterations = -1;
public GifRepeatBehavior IterationCount;
public GifRect Dimensions;
private GifColor[] _globarColorTable;
public GifColor[] GlobarColorTable;
}
}

43
Avalonia.Gif/Decoding/GifRect.cs

@ -0,0 +1,43 @@
namespace Avalonia.Gif.Decoding
{
public readonly struct GifRect
{
public int X { get; }
public int Y { get; }
public int Width { get; }
public int Height { get; }
public int TotalPixels { get; }
public GifRect(int x, int y, int width, int height)
{
X = x;
Y = y;
Width = width;
Height = height;
TotalPixels = width * height;
}
public static bool operator ==(GifRect a, GifRect b)
{
return a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height;
}
public static bool operator !=(GifRect a, GifRect b)
{
return !(a == b);
}
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
return false;
return this == (GifRect)obj;
}
public override int GetHashCode()
{
return X.GetHashCode() ^ Y.GetHashCode() | Width.GetHashCode() ^ Height.GetHashCode();
}
}
}

8
Avalonia.Gif/Decoding/GifRepeatBehavior.cs

@ -0,0 +1,8 @@
namespace Avalonia.Gif.Decoding
{
public class GifRepeatBehavior
{
public bool LoopForever { get; set; }
public int? Count { get; set; }
}
}

23
Avalonia.Gif/Decoding/InvalidGifStreamException.cs

@ -0,0 +1,23 @@
// Licensed under the MIT License.
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
using System;
using System.Runtime.Serialization;
namespace Avalonia.Gif.Decoding
{
[Serializable]
public class InvalidGifStreamException : Exception
{
public InvalidGifStreamException() { }
public InvalidGifStreamException(string message)
: base(message) { }
public InvalidGifStreamException(string message, Exception innerException)
: base(message, innerException) { }
protected InvalidGifStreamException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}
}

23
Avalonia.Gif/Decoding/LzwDecompressionException.cs

@ -0,0 +1,23 @@
// Licensed under the MIT License.
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
using System;
using System.Runtime.Serialization;
namespace Avalonia.Gif.Decoding
{
[Serializable]
public class LzwDecompressionException : Exception
{
public LzwDecompressionException() { }
public LzwDecompressionException(string message)
: base(message) { }
public LzwDecompressionException(string message, Exception innerException)
: base(message, innerException) { }
protected LzwDecompressionException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}
}

81
Avalonia.Gif/Extensions/StreamExtensions.cs

@ -0,0 +1,81 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
namespace Avalonia.Gif.Extensions
{
[DebuggerStepThrough]
internal static class StreamExtensions
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ushort SpanToShort(Span<byte> b) => (ushort)(b[0] | (b[1] << 8));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Skip(this Stream stream, long count)
{
stream.Position += count;
}
/// <summary>
/// Read a Gif block from stream while advancing the position.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int ReadBlock(this Stream stream, byte[] tempBuf)
{
stream.Read(tempBuf, 0, 1);
var blockLength = (int)tempBuf[0];
if (blockLength > 0)
stream.Read(tempBuf, 0, blockLength);
// Guard against infinite loop.
if (stream.Position >= stream.Length)
throw new InvalidGifStreamException("Reach the end of the filestream without trailer block.");
return blockLength;
}
/// <summary>
/// Skips GIF blocks until it encounters an empty block.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void SkipBlocks(this Stream stream, byte[] tempBuf)
{
int blockLength;
do
{
stream.Read(tempBuf, 0, 1);
blockLength = tempBuf[0];
stream.Position += blockLength;
// Guard against infinite loop.
if (stream.Position >= stream.Length)
throw new InvalidGifStreamException("Reach the end of the filestream without trailer block.");
} while (blockLength > 0);
}
/// <summary>
/// Read a <see cref="ushort"/> from stream by providing a temporary buffer.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ushort ReadUShortS(this Stream stream, byte[] tempBuf)
{
stream.Read(tempBuf, 0, 2);
return SpanToShort(tempBuf);
}
/// <summary>
/// Read a <see cref="ushort"/> from stream by providing a temporary buffer.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte ReadByteS(this Stream stream, byte[] tempBuf)
{
stream.Read(tempBuf, 0, 1);
var finalVal = tempBuf[0];
return finalVal;
}
}
}

297
Avalonia.Gif/GifImage.cs

@ -0,0 +1,297 @@
using System;
using System.IO;
using System.Numerics;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Logging;
using Avalonia.Media;
using Avalonia.Rendering.Composition;
using Avalonia.VisualTree;
namespace Avalonia.Gif
{
public class GifImage : Control
{
public static readonly StyledProperty<string> SourceUriRawProperty = AvaloniaProperty.Register<
GifImage,
string
>("SourceUriRaw");
public static readonly StyledProperty<Uri> SourceUriProperty = AvaloniaProperty.Register<GifImage, Uri>(
"SourceUri"
);
public static readonly StyledProperty<Stream> SourceStreamProperty = AvaloniaProperty.Register<
GifImage,
Stream
>("SourceStream");
public static readonly StyledProperty<IterationCount> IterationCountProperty = AvaloniaProperty.Register<
GifImage,
IterationCount
>("IterationCount", IterationCount.Infinite);
private IGifInstance? _gifInstance;
public static readonly StyledProperty<StretchDirection> StretchDirectionProperty = AvaloniaProperty.Register<
GifImage,
StretchDirection
>("StretchDirection");
public static readonly StyledProperty<Stretch> StretchProperty = AvaloniaProperty.Register<GifImage, Stretch>(
"Stretch"
);
private CompositionCustomVisual? _customVisual;
private object? _initialSource = null;
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
switch (change.Property.Name)
{
case nameof(SourceUriRaw):
case nameof(SourceUri):
case nameof(SourceStream):
SourceChanged(change);
break;
case nameof(Stretch):
case nameof(StretchDirection):
InvalidateArrange();
InvalidateMeasure();
Update();
break;
case nameof(IterationCount):
IterationCountChanged(change);
break;
case nameof(Bounds):
Update();
break;
}
base.OnPropertyChanged(change);
}
public string SourceUriRaw
{
get => GetValue(SourceUriRawProperty);
set => SetValue(SourceUriRawProperty, value);
}
public Uri SourceUri
{
get => GetValue(SourceUriProperty);
set => SetValue(SourceUriProperty, value);
}
public Stream SourceStream
{
get => GetValue(SourceStreamProperty);
set => SetValue(SourceStreamProperty, value);
}
public IterationCount IterationCount
{
get => GetValue(IterationCountProperty);
set => SetValue(IterationCountProperty, value);
}
public StretchDirection StretchDirection
{
get => GetValue(StretchDirectionProperty);
set => SetValue(StretchDirectionProperty, value);
}
public Stretch Stretch
{
get => GetValue(StretchProperty);
set => SetValue(StretchProperty, value);
}
private static void IterationCountChanged(AvaloniaPropertyChangedEventArgs e)
{
var image = e.Sender as GifImage;
if (image is null || e.NewValue is not IterationCount iterationCount)
return;
image.IterationCount = iterationCount;
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
var compositor = ElementComposition.GetElementVisual(this)?.Compositor;
if (compositor == null || _customVisual?.Compositor == compositor)
return;
_customVisual = compositor.CreateCustomVisual(new CustomVisualHandler());
ElementComposition.SetElementChildVisual(this, _customVisual);
_customVisual.SendHandlerMessage(CustomVisualHandler.StartMessage);
if (_initialSource is not null)
{
UpdateGifInstance(_initialSource);
_initialSource = null;
}
Update();
base.OnAttachedToVisualTree(e);
}
private void Update()
{
if (_customVisual is null || _gifInstance is null)
return;
var dpi = this.GetVisualRoot()?.RenderScaling ?? 1.0;
var sourceSize = _gifInstance.GifPixelSize.ToSize(dpi);
var viewPort = new Rect(Bounds.Size);
var scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection);
var scaledSize = sourceSize * scale;
var destRect = viewPort.CenterRect(new Rect(scaledSize)).Intersect(viewPort);
if (Stretch == Stretch.None)
{
_customVisual.Size = new Vector2((float)sourceSize.Width, (float)sourceSize.Height);
}
else
{
_customVisual.Size = new Vector2((float)destRect.Size.Width, (float)destRect.Size.Height);
}
_customVisual.Offset = new Vector3((float)destRect.Position.X, (float)destRect.Position.Y, 0);
}
private class CustomVisualHandler : CompositionCustomVisualHandler
{
private TimeSpan _animationElapsed;
private TimeSpan? _lastServerTime;
private IGifInstance? _currentInstance;
private bool _running;
public static readonly object StopMessage = new(),
StartMessage = new();
public override void OnMessage(object message)
{
if (message == StartMessage)
{
_running = true;
_lastServerTime = null;
RegisterForNextAnimationFrameUpdate();
}
else if (message == StopMessage)
{
_running = false;
}
else if (message is IGifInstance instance)
{
_currentInstance?.Dispose();
_currentInstance = instance;
}
}
public override void OnAnimationFrameUpdate()
{
if (!_running)
return;
Invalidate();
RegisterForNextAnimationFrameUpdate();
}
public override void OnRender(ImmediateDrawingContext drawingContext)
{
if (_running)
{
if (_lastServerTime.HasValue)
_animationElapsed += (CompositionNow - _lastServerTime.Value);
_lastServerTime = CompositionNow;
}
try
{
if (_currentInstance is null || _currentInstance.IsDisposed)
return;
var bitmap = _currentInstance.ProcessFrameTime(_animationElapsed);
if (bitmap is not null)
{
drawingContext.DrawBitmap(
bitmap,
new Rect(_currentInstance.GifPixelSize.ToSize(1)),
GetRenderBounds()
);
}
}
catch (Exception e)
{
Logger.Sink?.Log(LogEventLevel.Error, "GifImage Renderer ", this, e.ToString());
}
}
}
/// <summary>
/// Measures the control.
/// </summary>
/// <param name="availableSize">The available size.</param>
/// <returns>The desired size of the control.</returns>
protected override Size MeasureOverride(Size availableSize)
{
var result = new Size();
var scaling = this.GetVisualRoot()?.RenderScaling ?? 1.0;
if (_gifInstance != null)
{
result = Stretch.CalculateSize(
availableSize,
_gifInstance.GifPixelSize.ToSize(scaling),
StretchDirection
);
}
return result;
}
/// <inheritdoc/>
protected override Size ArrangeOverride(Size finalSize)
{
if (_gifInstance is null)
return new Size();
var scaling = this.GetVisualRoot()?.RenderScaling ?? 1.0;
var sourceSize = _gifInstance.GifPixelSize.ToSize(scaling);
var result = Stretch.CalculateSize(finalSize, sourceSize);
return result;
}
private void SourceChanged(AvaloniaPropertyChangedEventArgs e)
{
if (
e.NewValue is null
|| (e.NewValue is string value && !Uri.IsWellFormedUriString(value, UriKind.Absolute))
)
{
return;
}
if (_customVisual is null)
{
_initialSource = e.NewValue;
return;
}
UpdateGifInstance(e.NewValue);
InvalidateArrange();
InvalidateMeasure();
Update();
}
private void UpdateGifInstance(object source)
{
_gifInstance?.Dispose();
_gifInstance = new WebpInstance(source);
// _gifInstance = new GifInstance(source);
_gifInstance.IterationCount = IterationCount;
_customVisual?.SendHandlerMessage(_gifInstance);
}
}
}

147
Avalonia.Gif/GifInstance.cs

@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Gif.Decoding;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace Avalonia.Gif
{
public class GifInstance : IGifInstance
{
public IterationCount IterationCount { get; set; }
public bool AutoStart { get; private set; } = true;
private readonly GifDecoder _gifDecoder;
private readonly WriteableBitmap? _targetBitmap;
private TimeSpan _totalTime;
private readonly List<TimeSpan> _frameTimes;
private uint _iterationCount;
private int _currentFrameIndex;
private readonly List<ulong> _colorTableIdList;
public CancellationTokenSource CurrentCts { get; }
internal GifInstance(object newValue)
: this(
newValue switch
{
Stream s => s,
Uri u => GetStreamFromUri(u),
string str => GetStreamFromString(str),
_ => throw new InvalidDataException("Unsupported source object")
}
) { }
public GifInstance(string uri)
: this(GetStreamFromString(uri)) { }
public GifInstance(Uri uri)
: this(GetStreamFromUri(uri)) { }
public GifInstance(Stream currentStream)
{
if (!currentStream.CanSeek)
throw new InvalidDataException("The provided stream is not seekable.");
if (!currentStream.CanRead)
throw new InvalidOperationException("Can't read the stream provided.");
currentStream.Seek(0, SeekOrigin.Begin);
CurrentCts = new CancellationTokenSource();
_gifDecoder = new GifDecoder(currentStream, CurrentCts.Token);
var pixSize = new PixelSize(_gifDecoder.Header.Dimensions.Width, _gifDecoder.Header.Dimensions.Height);
_targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
GifPixelSize = pixSize;
_totalTime = TimeSpan.Zero;
_frameTimes = _gifDecoder
.Frames
.Select(frame =>
{
_totalTime = _totalTime.Add(frame.FrameDelay);
return _totalTime;
})
.ToList();
_gifDecoder.RenderFrame(0, _targetBitmap);
}
private static Stream GetStreamFromString(string str)
{
if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res))
{
throw new InvalidCastException("The string provided can't be converted to URI.");
}
return GetStreamFromUri(res);
}
private static Stream GetStreamFromUri(Uri uri)
{
var uriString = uri.OriginalString.Trim();
if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares"))
{
return new FileStream(uriString, FileMode.Open, FileAccess.Read);
}
return AssetLoader.Open(uri);
}
public int GifFrameCount => _frameTimes.Count;
public PixelSize GifPixelSize { get; }
public void Dispose()
{
IsDisposed = true;
CurrentCts.Cancel();
_targetBitmap?.Dispose();
}
public bool IsDisposed { get; private set; }
public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed)
{
if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value)
{
return null;
}
if (CurrentCts.IsCancellationRequested || _targetBitmap is null)
{
return null;
}
var elapsedTicks = stopwatchElapsed.Ticks;
var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks);
var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x);
var currentFrame = _frameTimes.IndexOf(targetFrame);
if (currentFrame == -1)
currentFrame = 0;
if (_currentFrameIndex == currentFrame)
return _targetBitmap;
_iterationCount = (uint)(elapsedTicks / _totalTime.Ticks);
return ProcessFrameIndex(currentFrame);
}
internal WriteableBitmap ProcessFrameIndex(int frameIndex)
{
_gifDecoder.RenderFrame(frameIndex, _targetBitmap);
_currentFrameIndex = frameIndex;
return _targetBitmap;
}
}
}

15
Avalonia.Gif/IGifInstance.cs

@ -0,0 +1,15 @@
using Avalonia.Animation;
using Avalonia.Media.Imaging;
namespace Avalonia.Gif;
public interface IGifInstance : IDisposable
{
IterationCount IterationCount { get; set; }
bool AutoStart { get; }
CancellationTokenSource CurrentCts { get; }
int GifFrameCount { get; }
PixelSize GifPixelSize { get; }
bool IsDisposed { get; }
WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed);
}

20
Avalonia.Gif/InvalidGifStreamException.cs

@ -0,0 +1,20 @@
using System;
using System.Runtime.Serialization;
namespace Avalonia.Gif
{
[Serializable]
internal class InvalidGifStreamException : Exception
{
public InvalidGifStreamException() { }
public InvalidGifStreamException(string message)
: base(message) { }
public InvalidGifStreamException(string message, Exception innerException)
: base(message, innerException) { }
protected InvalidGifStreamException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}
}

180
Avalonia.Gif/WebpInstance.cs

@ -0,0 +1,180 @@
using Avalonia.Animation;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using SkiaSharp;
namespace Avalonia.Gif;
public class WebpInstance : IGifInstance
{
public IterationCount IterationCount { get; set; }
public bool AutoStart { get; private set; } = true;
private readonly WriteableBitmap? _targetBitmap;
private TimeSpan _totalTime;
private readonly List<TimeSpan> _frameTimes;
private uint _iterationCount;
private int _currentFrameIndex;
private SKCodec? _codec;
public CancellationTokenSource CurrentCts { get; }
internal WebpInstance(object newValue)
: this(
newValue switch
{
Stream s => s,
Uri u => GetStreamFromUri(u),
string str => GetStreamFromString(str),
_ => throw new InvalidDataException("Unsupported source object")
}
) { }
public WebpInstance(string uri)
: this(GetStreamFromString(uri)) { }
public WebpInstance(Uri uri)
: this(GetStreamFromUri(uri)) { }
public WebpInstance(Stream currentStream)
{
if (!currentStream.CanSeek)
throw new InvalidDataException("The provided stream is not seekable.");
if (!currentStream.CanRead)
throw new InvalidOperationException("Can't read the stream provided.");
currentStream.Seek(0, SeekOrigin.Begin);
CurrentCts = new CancellationTokenSource();
var managedStream = new SKManagedStream(currentStream);
_codec = SKCodec.Create(managedStream);
var pixSize = new PixelSize(_codec.Info.Width, _codec.Info.Height);
_targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
GifPixelSize = pixSize;
_totalTime = TimeSpan.Zero;
_frameTimes = _codec
.FrameInfo
.Select(frame =>
{
_totalTime = _totalTime.Add(TimeSpan.FromMilliseconds(frame.Duration));
return _totalTime;
})
.ToList();
RenderFrame(_codec, _targetBitmap, 0);
}
private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index)
{
codec.GetFrameInfo(index, out var frameInfo);
var info = new SKImageInfo(codec.Info.Width, codec.Info.Height);
var decodeInfo = info.WithAlphaType(frameInfo.AlphaType);
using var frameBuffer = targetBitmap.Lock();
var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index));
if (result != SKCodecResult.Success)
throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}.");
}
private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index, int priorIndex)
{
codec.GetFrameInfo(index, out var frameInfo);
var info = new SKImageInfo(codec.Info.Width, codec.Info.Height);
var decodeInfo = info.WithAlphaType(frameInfo.AlphaType);
using var frameBuffer = targetBitmap.Lock();
var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index, priorIndex));
if (result != SKCodecResult.Success)
throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}.");
}
private static Stream GetStreamFromString(string str)
{
if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res))
{
throw new InvalidCastException("The string provided can't be converted to URI.");
}
return GetStreamFromUri(res);
}
private static Stream GetStreamFromUri(Uri uri)
{
var uriString = uri.OriginalString.Trim();
if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares"))
{
return new FileStream(uriString, FileMode.Open, FileAccess.Read);
}
return AssetLoader.Open(uri);
}
public int GifFrameCount => _frameTimes.Count;
public PixelSize GifPixelSize { get; }
public void Dispose()
{
IsDisposed = true;
CurrentCts.Cancel();
_targetBitmap?.Dispose();
_codec?.Dispose();
}
public bool IsDisposed { get; private set; }
public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed)
{
if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value)
{
return null;
}
if (CurrentCts.IsCancellationRequested || _targetBitmap is null)
{
return null;
}
var elapsedTicks = stopwatchElapsed.Ticks;
var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks);
var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x);
var currentFrame = _frameTimes.IndexOf(targetFrame);
if (currentFrame == -1)
currentFrame = 0;
if (_currentFrameIndex == currentFrame)
return _targetBitmap;
_iterationCount = (uint)(elapsedTicks / _totalTime.Ticks);
return ProcessFrameIndex(currentFrame);
}
internal WriteableBitmap ProcessFrameIndex(int frameIndex)
{
if (_codec is null)
throw new InvalidOperationException("The codec is null.");
if (_targetBitmap is null)
throw new InvalidOperationException("The target bitmap is null.");
RenderFrame(_codec, _targetBitmap, frameIndex, _currentFrameIndex);
_currentFrameIndex = frameIndex;
return _targetBitmap;
}
}

8
Build/AppEntitlements.entitlements

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

12
Build/EmbeddedEntitlements.entitlements

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

26
Build/build_macos_app.sh

@ -0,0 +1,26 @@
#!/bin/sh
while getopts v: flag
do
case "${flag}" in
v) version=${OPTARG};;
*) echo "Invalid option";;
esac
done
dotnet \
msbuild \
StabilityMatrix.Avalonia \
-t:BundleApp \
-p:RuntimeIdentifier=osx-arm64 \
-p:UseAppHost=true \
-p:Configuration=Release \
-p:CFBundleShortVersionString="$version" \
-p:SelfContained=true \
-p:CFBundleName="Stability Matrix" \
-p:CFBundleDisplayName="Stability Matrix" \
-p:CFBundleVersion="$version" \
-p:PublishDir="$(pwd)/out/osx-arm64/bin" \
# Copy the app out of bin
cp -r ./out/osx-arm64/bin/Stability\ Matrix.app ./out/osx-arm64/Stability\ Matrix.app

62
Build/codesign_embedded_macos.sh

@ -0,0 +1,62 @@
#!/bin/sh
echo "Signing file: $1"
# Setup keychain in CI
if [ -n "$CI" ]; then
# Turn our base64-encoded certificate back to a regular .p12 file
echo "$MACOS_CERTIFICATE" | base64 --decode -o certificate.p12
# We need to create a new keychain, otherwise using the certificate will prompt
# with a UI dialog asking for the certificate password, which we can't
# use in a headless CI environment
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain
fi
# Sign all files
PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" || return ; pwd -P )
ENTITLEMENTS="$PARENT_PATH/EmbeddedEntitlements.entitlements"
echo "Using entitlements file: $ENTITLEMENTS"
# App
if [ "$1" == "*.app" ]; then
echo "[INFO] Signing app contents"
find "$1/Contents/MacOS/"|while read fname; do
if [[ -f $fname ]]; then
echo "[INFO] Signing $fname"
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname"
fi
done
echo "[INFO] Signing app file"
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v
# Directory
elif [ -d "$1" ]; then
echo "[INFO] Signing directory contents"
find "$1"|while read fname; do
if [[ -f $fname ]] && [[ ! $fname =~ /(*.(py|msg|enc))/ ]]; then
echo "[INFO] Signing $fname"
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname"
fi
done
# File
elif [ -f "$1" ]; then
echo "[INFO] Signing file"
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v
# Not matched
else
echo "[ERROR] Unknown file type"
exit 1
fi

37
Build/codesign_macos.sh

@ -0,0 +1,37 @@
#!/bin/sh
echo "Signing file: $1"
# Setup keychain in CI
if [ -n "$CI" ]; then
# Turn our base64-encoded certificate back to a regular .p12 file
echo "$MACOS_CERTIFICATE" | base64 --decode -o certificate.p12
# We need to create a new keychain, otherwise using the certificate will prompt
# with a UI dialog asking for the certificate password, which we can't
# use in a headless CI environment
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain
fi
# Sign all files
PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" || return ; pwd -P )
ENTITLEMENTS="$PARENT_PATH/AppEntitlements.entitlements"
echo "Using entitlements file: $ENTITLEMENTS"
find "$1/Contents/MacOS/"|while read fname; do
if [[ -f $fname ]]; then
echo "[INFO] Signing $fname"
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname"
fi
done
echo "[INFO] Signing app file"
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v

32
Build/notarize_macos.sh

@ -0,0 +1,32 @@
#!/bin/sh
echo "Notarizing file: $1"
# Store the notarization credentials so that we can prevent a UI password dialog
# from blocking the CI
echo "Create keychain profile"
xcrun notarytool store-credentials "notarytool-profile" \
--apple-id "$MACOS_NOTARIZATION_APPLE_ID" \
--team-id "$MACOS_NOTARIZATION_TEAM_ID" \
--password "$MACOS_NOTARIZATION_PWD"
# We can't notarize an app bundle directly, but we need to compress it as an archive.
# Therefore, we create a zip file containing our app bundle, so that we can send it to the
# notarization service
echo "Creating temp notarization archive"
ditto -c -k --keepParent "$1" "notarization.zip"
# Here we send the notarization request to the Apple's Notarization service, waiting for the result.
# This typically takes a few seconds inside a CI environment, but it might take more depending on the App
# characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if
# you're curious
echo "Notarize app"
xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait
# Finally, we need to "attach the staple" to our executable, which will allow our app to be
# validated by macOS even when an internet connection is not available.
echo "Attach staple"
xcrun stapler staple "$1"

38
CHANGELOG.md

@ -5,6 +5,44 @@ 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.8.0-dev.4
### Added
- Auto-update support for macOS
- New package installation flow
- Added `--use-directml` launch argument for SDWebUI DirectML fork
### Changed
- Changed default Period to "AllTime" in the Model Browser
### Fixed
- Fixed SDTurboScheduler's missing denoise parameter
## v2.8.0-dev.3
### Added
- Added release builds for macOS (Apple Silicon)
- Added new package: [OneTrainer](https://github.com/Nerogar/OneTrainer)
- Added ComfyUI launch argument configs: Cross Attention Method, Force Floating Point Precision, VAE Precision
- Added Delete button to the CivitAI Model Browser details dialog
- Added "Copy Link to Clipboard" for connected models in the Checkpoints page
### Changed
- Python Packages install dialog now allows entering multiple arguments or option flags
### Fixed
- Fixed environment variables grid not being editable related to [Avalonia #13843](https://github.com/AvaloniaUI/Avalonia/issues/13843)
## v2.8.0-dev.2
### Added
#### Inference
- Added Image to Video project type
#### Output Browser
- Added support for webp files
- Added "Send to Image to Image" and "Send to Image to Video" options to the context menu
### Changed
- Changed how settings file is written to disk to reduce potential data loss risk
## v2.8.0-dev.1
### Added
#### Inference
- Added image and model details in model selection boxes
- Added CLIP Skip setting, toggleable from the model settings button
## v2.7.7
### Added
- Added `--use-directml` launch argument for SDWebUI DirectML fork

6
README.md

@ -6,7 +6,7 @@
[release]: https://github.com/LykosAI/StabilityMatrix/releases/latest
[download-win-x64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-win-x64.zip
[download-linux-x64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-linux-x64.zip
[download-macos]: https://github.com/LykosAI/StabilityMatrix/issues/45
[download-macos-arm64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-macos-arm64.dmg
[auto1111]: https://github.com/AUTOMATIC1111/stable-diffusion-webui
[comfy]: https://github.com/comfyanonymous/ComfyUI
@ -44,9 +44,7 @@ Multi-Platform Package Manager and Inference UI for Stable Diffusion
[![Windows](https://img.shields.io/badge/Windows-%230079d5.svg?style=for-the-badge&logo=Windows%2011&logoColor=white)][download-win-x64]
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)][download-linux-x64]
[![macOS](https://img.shields.io/badge/mac%20os%20%28apple%20silicon%29-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)][download-macos]
> macOS builds are currently pending: [#45][download-macos]
[![macOS](https://img.shields.io/badge/mac%20os%20%28apple%20silicon%29-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)][download-macos-arm64]
### Inference - A reimagined built-in Stable Diffusion experience
- Powerful auto-completion and syntax highlighting using a formal language grammar

1
StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj

@ -21,6 +21,7 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.5" />
<PackageReference Include="DotNet.Bundle" Version="0.9.13" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />

4
StabilityMatrix.Avalonia/App.axaml

@ -4,6 +4,7 @@
xmlns:local="using:StabilityMatrix.Avalonia"
xmlns:idcr="using:Dock.Avalonia.Controls.Recycling"
xmlns:styling="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia"
Name="Stability Matrix"
RequestedThemeVariant="Dark">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
@ -23,6 +24,7 @@
<ResourceInclude Source="Controls/Scroll/BetterScrollViewer.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/HyperlinkIconButtonStyles.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/ListBoxStyles.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/BetterComboBoxStyles.axaml"/>
<ResourceInclude Source="Styles/ListBoxStyles.axaml"/>
<ResourceInclude Source="Styles/FAComboBoxStyles.axaml"/>
<ResourceInclude Source="Controls/Inference/ImageFolderCard.axaml"/>
@ -58,6 +60,8 @@
<StyleInclude Source="Controls/SelectableImageCard/SelectableImageButton.axaml"/>
<StyleInclude Source="Controls/SettingsAccountLinkExpander.axaml"/>
<StyleInclude Source="Controls/StarsRating.axaml"/>
<StyleInclude Source="Controls/VideoGenerationSettingsCard.axaml"/>
<StyleInclude Source="Controls/VideoOutputSettingsCard.axaml"/>
<StyleInclude Source="Controls/Inference/StackCard.axaml"/>
<StyleInclude Source="Controls/Inference/StackEditableCard.axaml"/>
<StyleInclude Source="Controls/Inference/StackExpander.axaml"/>

148
StabilityMatrix.Avalonia/App.axaml.cs

@ -80,7 +80,8 @@ public sealed class App : Application
public static TopLevel TopLevel => TopLevel.GetTopLevel(VisualRoot)!;
internal static bool IsHeadlessMode => TopLevel.TryGetPlatformHandle()?.HandleDescriptor is null or "STUB";
internal static bool IsHeadlessMode =>
TopLevel.TryGetPlatformHandle()?.HandleDescriptor is null or "STUB";
[NotNull]
public static IStorageProvider? StorageProvider { get; internal set; }
@ -117,8 +118,7 @@ public sealed class App : Application
{
// Remove DataAnnotations validation plugin since we're using INotifyDataErrorInfo from MvvmToolkit
var dataValidationPluginsToRemove = BindingPlugins
.DataValidators
.OfType<DataAnnotationsValidationPlugin>()
.DataValidators.OfType<DataAnnotationsValidationPlugin>()
.ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
@ -161,10 +161,7 @@ public sealed class App : Application
DesktopLifetime.MainWindow = setupWindow;
setupWindow
.ShowAsyncCts
.Token
.Register(() =>
setupWindow.ShowAsyncCts.Token.Register(() =>
{
if (setupWindow.Result == ContentDialogResult.Primary)
{
@ -223,9 +220,6 @@ public sealed class App : Application
mainWindow.WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
mainWindow.Closing += OnMainWindowClosing;
mainWindow.Closed += (_, _) => Shutdown();
mainWindow.SetDefaultFonts();
VisualRoot = mainWindow;
@ -234,6 +228,7 @@ public sealed class App : Application
DesktopLifetime.MainWindow = mainWindow;
DesktopLifetime.Exit += OnExit;
DesktopLifetime.ShutdownRequested += OnShutdownRequested;
}
private static void ConfigureServiceProvider()
@ -276,7 +271,7 @@ public sealed class App : Application
{
provider.GetRequiredService<LaunchPageViewModel>(),
provider.GetRequiredService<InferenceViewModel>(),
provider.GetRequiredService<PackageManagerViewModel>(),
provider.GetRequiredService<NewPackageManagerViewModel>(),
provider.GetRequiredService<CheckpointsPageViewModel>(),
provider.GetRequiredService<CheckpointBrowserViewModel>(),
provider.GetRequiredService<OutputsPageViewModel>()
@ -297,7 +292,9 @@ public sealed class App : Application
var serviceManager = new ServiceManager<ViewModelBase>();
var serviceManagedTypes = exportedTypes
.Select(t => new { t, attributes = t.GetCustomAttributes(typeof(ManagedServiceAttribute), true) })
.Select(
t => new { t, attributes = t.GetCustomAttributes(typeof(ManagedServiceAttribute), true) }
)
.Where(t1 => t1.attributes is { Length: > 0 })
.Select(t1 => t1.t)
.ToList();
@ -322,8 +319,7 @@ public sealed class App : Application
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix");
var exportedTypes = AppDomain
.CurrentDomain
.GetAssemblies()
.CurrentDomain.GetAssemblies()
.Where(a => a.FullName?.StartsWith("StabilityMatrix") == true)
.SelectMany(a => a.GetExportedTypes())
.ToArray();
@ -332,7 +328,8 @@ public sealed class App : Application
.Select(t => new { t, attributes = t.GetCustomAttributes(typeof(TransientAttribute), false) })
.Where(
t1 =>
t1.attributes is { Length: > 0 } && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)
t1.attributes is { Length: > 0 }
&& !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)
)
.Select(t1 => new { Type = t1.t, Attribute = (TransientAttribute)t1.attributes[0] });
@ -352,9 +349,12 @@ public sealed class App : Application
.Select(t => new { t, attributes = t.GetCustomAttributes(typeof(SingletonAttribute), false) })
.Where(
t1 =>
t1.attributes is { Length: > 0 } && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)
t1.attributes is { Length: > 0 }
&& !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)
)
.Select(t1 => new { Type = t1.t, Attributes = t1.attributes.Cast<SingletonAttribute>().ToArray() });
.Select(
t1 => new { Type = t1.t, Attributes = t1.attributes.Cast<SingletonAttribute>().ToArray() }
);
foreach (var typePair in singletonTypes)
{
@ -386,7 +386,9 @@ public sealed class App : Application
// Rich presence
services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>();
services.AddSingleton<IDisposable>(provider => provider.GetRequiredService<IDiscordRichPresenceService>());
services.AddSingleton<IDisposable>(
provider => provider.GetRequiredService<IDiscordRichPresenceService>()
);
Config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
@ -557,7 +559,11 @@ public sealed class App : Application
#if DEBUG
builder.AddNLog(
ConfigureLogging(),
new NLogProviderOptions { IgnoreEmptyEventId = false, CaptureEventId = EventIdCaptureType.Legacy }
new NLogProviderOptions
{
IgnoreEmptyEventId = false,
CaptureEventId = EventIdCaptureType.Legacy
}
);
#else
builder.AddNLog(ConfigureLogging());
@ -577,42 +583,52 @@ public sealed class App : Application
{
if (Current is null)
throw new NullReferenceException("Current Application was null when Shutdown called");
if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
{
lifetime.Shutdown(exitCode);
try
{
var result = lifetime.TryShutdown(exitCode);
Debug.WriteLine($"Shutdown: {result}");
if (result)
{
Environment.Exit(exitCode);
}
}
/// <summary>
/// Handle shutdown requests (happens before <see cref="OnExit"/>)
/// </summary>
private static void OnMainWindowClosing(object? sender, WindowClosingEventArgs e)
catch (InvalidOperationException)
{
if (e.Cancel)
return;
var mainWindow = (MainWindow)sender!;
// Ignore in case already shutting down
}
}
else
{
Environment.Exit(exitCode);
}
}
// Show confirmation if package running
var launchPageViewModel = Services.GetRequiredService<LaunchPageViewModel>();
launchPageViewModel.OnMainWindowClosing(e);
private static void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e)
{
Debug.WriteLine("Start OnShutdownRequested");
if (e.Cancel)
return;
// Check if we need to dispose IAsyncDisposables
if (
!isAsyncDisposeComplete
&& Services.GetServices<IAsyncDisposable>().ToList() is { Count: > 0 } asyncDisposables
isAsyncDisposeComplete
|| Services.GetServices<IAsyncDisposable>().ToList() is not { Count: > 0 } asyncDisposables
)
{
return;
// Cancel shutdown for now
e.Cancel = true;
isAsyncDisposeComplete = true;
Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables");
Task.Run(async () =>
Dispatcher
.UIThread.InvokeAsync(async () =>
{
foreach (var disposable in asyncDisposables)
{
@ -630,38 +646,16 @@ public sealed class App : Application
.ContinueWith(_ =>
{
// Shutdown again
Dispatcher.UIThread.Invoke(() => Shutdown());
})
.SafeFireAndForget();
return;
}
Debug.WriteLine("Finished disposing IAsyncDisposables, shutting down");
OnMainWindowClosingTerminal(mainWindow);
}
/// <summary>
/// Called at the end of <see cref="OnMainWindowClosing"/> before the main window is closed.
/// </summary>
private static void OnMainWindowClosingTerminal(Window sender)
if (Dispatcher.UIThread.SupportsRunLoops)
{
var settingsManager = Services.GetRequiredService<ISettingsManager>();
// Save window position
var validWindowPosition = sender.Screens.All.Any(screen => screen.Bounds.Contains(sender.Position));
Dispatcher.UIThread.Invoke(() => Shutdown());
}
settingsManager.Transaction(
s =>
{
s.WindowSettings = new WindowSettings(
sender.Width,
sender.Height,
validWindowPosition ? sender.Position.X : 0,
validWindowPosition ? sender.Position.Y : 0
);
},
ignoreMissingLibraryDir: true
);
Environment.Exit(0);
})
.SafeFireAndForget();
}
private static void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args)
@ -714,10 +708,12 @@ public sealed class App : Application
.WriteTo(
new FileTarget
{
Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}",
Layout =
"${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}",
ArchiveOldFileOnStartup = true,
FileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.log",
ArchiveFileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.{#}.log",
ArchiveFileName =
"${specialfolder:folder=ApplicationData}/StabilityMatrix/app.{#}.log",
ArchiveNumbering = ArchiveNumberingMode.Rolling,
MaxArchiveFiles = 2
}
@ -730,7 +726,9 @@ public sealed class App : Application
builder.ForLogger("Microsoft.Extensions.Http.*").WriteToNil(NLog.LogLevel.Warn);
// Disable console trace logging by default
builder.ForLogger("StabilityMatrix.Avalonia.ViewModels.ConsoleViewModel").WriteToNil(NLog.LogLevel.Debug);
builder
.ForLogger("StabilityMatrix.Avalonia.ViewModels.ConsoleViewModel")
.WriteToNil(NLog.LogLevel.Debug);
// Disable LoadableViewModelBase trace logging by default
builder
@ -751,9 +749,7 @@ public sealed class App : Application
// Sentry
if (SentrySdk.IsEnabled)
{
LogManager
.Configuration
.AddSentry(o =>
LogManager.Configuration.AddSentry(o =>
{
o.InitializeSdk = false;
o.Layout = "${message}";
@ -803,12 +799,14 @@ public sealed class App : Application
results.Add(ms);
}
Dispatcher
.UIThread
.InvokeAsync(async () =>
Dispatcher.UIThread.InvokeAsync(async () =>
{
var dest = await StorageProvider.SaveFilePickerAsync(
new FilePickerSaveOptions() { SuggestedFileName = "screenshot.png", ShowOverwritePrompt = true }
new FilePickerSaveOptions()
{
SuggestedFileName = "screenshot.png",
ShowOverwritePrompt = true
}
);
if (dest?.TryGetLocalPath() is { } localPath)

28
StabilityMatrix.Avalonia/Assets.cs

@ -11,16 +11,19 @@ namespace StabilityMatrix.Avalonia;
internal static class Assets
{
public static AvaloniaResource AppIcon { get; } = new("avares://StabilityMatrix.Avalonia/Assets/Icon.ico");
public static AvaloniaResource AppIcon { get; } =
new("avares://StabilityMatrix.Avalonia/Assets/Icon.ico");
public static AvaloniaResource AppIconPng { get; } = new("avares://StabilityMatrix.Avalonia/Assets/Icon.png");
public static AvaloniaResource AppIconPng { get; } =
new("avares://StabilityMatrix.Avalonia/Assets/Icon.png");
/// <summary>
/// Fixed image for models with no images.
/// </summary>
public static Uri NoImage { get; } = new("avares://StabilityMatrix.Avalonia/Assets/noimage.png");
public static AvaloniaResource LicensesJson => new("avares://StabilityMatrix.Avalonia/Assets/licenses.json");
public static AvaloniaResource LicensesJson =>
new("avares://StabilityMatrix.Avalonia/Assets/licenses.json");
public static AvaloniaResource ImagePromptLanguageJson =>
new("avares://StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json");
@ -28,7 +31,8 @@ internal static class Assets
public static AvaloniaResource ThemeMatrixDarkJson =>
new("avares://StabilityMatrix.Avalonia/Assets/ThemeMatrixDark.json");
public static AvaloniaResource HfPackagesJson => new("avares://StabilityMatrix.Avalonia/Assets/hf-packages.json");
public static AvaloniaResource HfPackagesJson =>
new("avares://StabilityMatrix.Avalonia/Assets/hf-packages.json");
private const UnixFileMode Unix755 =
UnixFileMode.UserRead
@ -44,7 +48,10 @@ internal static class Assets
[SupportedOSPlatform("macos")]
public static AvaloniaResource SevenZipExecutable =>
Compat.Switch(
(PlatformKind.Windows, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za.exe")),
(
PlatformKind.Windows,
new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za.exe")
),
(
PlatformKind.Linux | PlatformKind.X64,
new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs", Unix755)
@ -112,10 +119,9 @@ internal static class Assets
PlatformKind.MacOS | PlatformKind.Arm,
new RemoteResource
{
Url = new Uri(
"https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-aarch64-apple-darwin-install_only.tar.gz"
),
HashSha256 = "8348bc3c2311f94ec63751fb71bd0108174be1c4def002773cf519ee1506f96f"
// Requires our distribution with signed dylib for gatekeeper
Url = new Uri("https://cdn.lykos.ai/cpython-3.10.11-macos-arm64.zip"),
HashSha256 = "83c00486e0af9c460604a425e519d58e4b9604fbe7a4448efda0f648f86fb6e3"
}
)
);
@ -148,7 +154,9 @@ internal static class Assets
/// <summary>
/// Yield AvaloniaResources given a relative directory path within the 'Assets' folder.
/// </summary>
public static IEnumerable<(AvaloniaResource resource, string relativePath)> FindAssets(string relativeAssetPath)
public static IEnumerable<(AvaloniaResource resource, string relativePath)> FindAssets(
string relativeAssetPath
)
{
var baseUri = new Uri("avares://StabilityMatrix.Avalonia/Assets/");
var targetUri = new Uri(baseUri, relativeAssetPath);

BIN
StabilityMatrix.Avalonia/Assets/AppIcon.icns

Binary file not shown.

21
StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml

@ -8,14 +8,24 @@
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:gif="clr-namespace:Avalonia.Gif;assembly=Avalonia.Gif"
d:DataContext="{x:Static mocks:DesignData.SampleImageSource}"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="models:ImageSource"
mc:Ignorable="d">
<Grid>
<ContentPresenter Content="{Binding}">
<ContentPresenter.ContentTemplate>
<controls:DataTemplateSelector x:TypeArguments="models:ImageSourceTemplateType">
<DataTemplate x:Key="{x:Static models:ImageSourceTemplateType.WebpAnimation}" DataType="models:ImageSource">
<gif:GifImage
Stretch="Uniform"
SourceUri="{Binding LocalFile.FullPath}"/>
</DataTemplate>
<DataTemplate x:Key="{x:Static models:ImageSourceTemplateType.Image}" DataType="models:ImageSource">
<controls:AdvancedImageBox
Name="ImageBox"
CornerRadius="4"
Image="{Binding BitmapAsync^}"
SizeMode="Fit">
@ -23,15 +33,18 @@
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem
x:Name="CopyMenuItem"
CommandParameter="{Binding #ImageBox.Image}"
CommandParameter="{Binding $parent[controls:AdvancedImageBox].Image}"
HotKey="Ctrl+C"
IconSource="Copy"
IsEnabled="{OnPlatform Windows=True,
Default=False}"
IsEnabled="{OnPlatform Windows=True, Default=False}"
Text="Copy" />
</ui:FAMenuFlyout>
</controls:AdvancedImageBox.ContextFlyout>
</controls:AdvancedImageBox>
</DataTemplate>
</controls:DataTemplateSelector>
</ContentPresenter.ContentTemplate>
</ContentPresenter>
<!-- Label pill card -->
<Border

7
StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml.cs

@ -21,13 +21,16 @@ public partial class AdvancedImageBoxView : UserControl
{
base.OnApplyTemplate(e);
var copyMenuItem = this.FindControl<MenuFlyoutItem>("CopyMenuItem")!;
if (this.FindControl<MenuFlyoutItem>("CopyMenuItem") is { } copyMenuItem)
{
copyMenuItem.Command = new AsyncRelayCommand<Bitmap?>(FlyoutCopy);
}
}
private static async Task FlyoutCopy(Bitmap? image)
{
if (image is null || !Compat.IsWindows) return;
if (image is null || !Compat.IsWindows)
return;
await Task.Run(() =>
{

41
StabilityMatrix.Avalonia/Controls/BetterComboBox.cs

@ -0,0 +1,41 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
namespace StabilityMatrix.Avalonia.Controls;
public class BetterComboBox : ComboBox
{
// protected override Type StyleKeyOverride { get; } = typeof(CheckBox);
public static readonly DirectProperty<BetterComboBox, IDataTemplate?> SelectionBoxItemTemplateProperty =
AvaloniaProperty.RegisterDirect<BetterComboBox, IDataTemplate?>(
nameof(SelectionBoxItemTemplate),
v => v.SelectionBoxItemTemplate,
(x, v) => x.SelectionBoxItemTemplate = v
);
private IDataTemplate? _selectionBoxItemTemplate;
public IDataTemplate? SelectionBoxItemTemplate
{
get => _selectionBoxItemTemplate;
private set => SetAndRaise(SelectionBoxItemTemplateProperty, ref _selectionBoxItemTemplate, value);
}
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (e.NameScope.Find<ContentControl>("ContentPresenter") is { } contentPresenter)
{
if (SelectionBoxItemTemplate is { } template)
{
contentPresenter.ContentTemplate = template;
}
}
}
}

46
StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Metadata;
using JetBrains.Annotations;
using StabilityMatrix.Avalonia.Models;
namespace StabilityMatrix.Avalonia.Controls;
/// <summary>
/// Selector for objects implementing <see cref="ITemplateKey{T}"/>
/// </summary>
[PublicAPI]
public class DataTemplateSelector<TKey> : IDataTemplate
where TKey : notnull
{
/// <summary>
/// Key that is used when no other key matches
/// </summary>
public TKey? DefaultKey { get; set; }
[Content]
public Dictionary<TKey, IDataTemplate> Templates { get; } = new();
public bool Match(object? data) => data is ITemplateKey<TKey>;
/// <inheritdoc />
public Control Build(object? data)
{
if (data is not ITemplateKey<TKey> key)
throw new ArgumentException(null, nameof(data));
if (Templates.TryGetValue(key.TemplateKey, out var template))
{
return template.Build(data)!;
}
if (DefaultKey is not null && Templates.TryGetValue(DefaultKey, out var defaultTemplate))
{
return defaultTemplate.Build(data)!;
}
throw new ArgumentException(null, nameof(data));
}
}

157
StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml

@ -7,11 +7,14 @@
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
x:DataType="inference:ModelCardViewModel">
<Design.PreviewWith>
<Panel Width="400" Height="200">
<StackPanel Width="300" VerticalAlignment="Center">
<controls:ModelCard DataContext="{x:Static mocks:DesignData.ModelCardViewModel}" />
<controls:ModelCard DataContext="{x:Static mocks:DesignData.ImgToVidModelCardViewModel}" />
</StackPanel>
</Panel>
</Design.PreviewWith>
@ -21,126 +24,39 @@
<Setter Property="Template">
<ControlTemplate>
<controls:Card Padding="12">
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="*,*,*">
<sg:SpacedGrid
ColumnSpacing="8"
RowSpacing="0"
ColumnDefinitions="Auto,*,Auto"
RowDefinitions="*,*,*,*">
<!-- Model -->
<TextBlock
Grid.Column="0"
MinWidth="60"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_Model}"
TextAlignment="Left" />
<ui:FAComboBox
<controls:BetterComboBox
Grid.Row="0"
Grid.Column="1"
Theme="{StaticResource FAComboBoxHybridModelTheme}"
Padding="8,6,4,6"
Theme="{StaticResource BetterComboBoxHybridModelTheme}"
HorizontalAlignment="Stretch"
ItemsSource="{Binding ClientManager.Models}"
SelectedItem="{Binding SelectedModel}">
<!--<ui:FAComboBox.Styles>
<Style Selector="ui|FAComboBoxItem">
<Setter Property="ToolTip.Placement" Value="RightEdgeAlignedTop" />
<Setter Property="ToolTip.Tip">
<Template>
<StackPanel
x:DataType="models:HybridModelFile"
Orientation="Horizontal"
Spacing="6">
~1~ Image @1@
<controls:BetterAdvancedImage
Width="64"
Height="96"
CornerRadius="6"
IsVisible="{Binding Local.PreviewImageFullPathGlobal, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=''}"
RenderOptions.BitmapInterpolationMode="HighQuality"
Source="{Binding Local.PreviewImageFullPathGlobal, FallbackValue=''}"
Stretch="UniformToFill"
StretchDirection="Both" />
<Grid RowDefinitions="Auto,Auto,*" VerticalAlignment="Stretch" MaxWidth="300">
~1~ Title @1@
<TextBlock
Margin="0,0,0,4"
HorizontalAlignment="Left"
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsVisible="{Binding Local.ConnectedModelInfo, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}"
Text="{Binding Local.ConnectedModelInfo.ModelName, FallbackValue=''}"
TextWrapping="WrapWithOverflow" />
~1~ Version @1@
<TextBlock
Grid.Row="1"
Margin="0,0,0,8"
HorizontalAlignment="Left"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
IsVisible="{Binding Local.ConnectedModelInfo, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}"
Text="{Binding Local.ConnectedModelInfo.VersionName, FallbackValue=''}"
TextWrapping="WrapWithOverflow" />
~1~ Path @1@
<TextBlock
Grid.Row="2"
HorizontalAlignment="Left"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding RelativePath}"
TextWrapping="Wrap" />
</Grid>
</StackPanel>
</Template>
</Setter>
</Style>
<Style Selector="ui|FAComboBox /template/ ContentControl#ContentPresenter &gt; StackPanel &gt; TextBlock:nth-child(2)">
<Setter Property="IsVisible" Value="False" />
</Style>
</ui:FAComboBox.Styles>
<ui:FAComboBox.ItemTemplate>
<DataTemplate DataType="models:HybridModelFile">
<StackPanel ToolTip.Placement="RightEdgeAlignedTop">
<TextBlock Text="{Binding ShortDisplayName}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ui:FAComboBox.ItemTemplate>-->
<!--<ui:FAComboBox.ItemTemplate>
<DataTemplate DataType="models:HybridModelFile">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*">
<controls:BetterAdvancedImage
Grid.Row="0"
Grid.Column="0"
Grid.RowSpan="2"
Margin="0,0,8,0"
Height="32"
Width="32"
Stretch="UniformToFill"
StretchDirection="Both"
RenderOptions.BitmapInterpolationMode="HighQuality"
Source="{Binding Local.PreviewImageFullPathGlobal}">
<controls:BetterAdvancedImage.Clip>
<EllipseGeometry Rect="0,0,32,32" />
</controls:BetterAdvancedImage.Clip>
</controls:BetterAdvancedImage>
<TextBlock
Grid.Row="0"
Grid.Column="1"
Text="{Binding Local.ConnectedModelInfo.VersionName}"/>
<TextBlock
FontSize="11"
Grid.Row="1"
Grid.Column="1"
Text="{Binding Local.ConnectedModelInfo.ModelName}"/>
</Grid>
</DataTemplate>
</ui:FAComboBox.ItemTemplate>-->
</ui:FAComboBox>
SelectedItem="{Binding SelectedModel}"/>
<Button
Grid.Row="0"
Grid.Column="2"
Margin="8,0,0,0">
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Margin="0,0,0,0">
<ui:SymbolIcon FontSize="16" Symbol="Setting" />
<Button.Flyout>
<ui:FAMenuFlyout Placement="BottomEdgeAlignedLeft">
<ui:ToggleMenuFlyoutItem IsChecked="{Binding IsRefinerSelectionEnabled}" Text="{x:Static lang:Resources.Label_Refiner}" />
<ui:ToggleMenuFlyoutItem IsChecked="{Binding IsVaeSelectionEnabled}" Text="{x:Static lang:Resources.Label_VAE}" />
<ui:ToggleMenuFlyoutItem IsChecked="{Binding IsClipSkipEnabled}" Text="{x:Static lang:Resources.Label_CLIPSkip}" />
</ui:FAMenuFlyout>
</Button.Flyout>
</Button>
@ -149,30 +65,28 @@
<TextBlock
Grid.Row="1"
Grid.Column="0"
MinWidth="60"
Margin="0,8,0,0"
VerticalAlignment="Center"
IsVisible="{Binding IsRefinerSelectionEnabled}"
Text="{x:Static lang:Resources.Label_Refiner}"
TextAlignment="Left" />
<ui:FAComboBox
<controls:BetterComboBox
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
DisplayMemberBinding="{Binding ShortDisplayName}"
IsTextSearchEnabled="True"
Padding="8,6,4,6"
IsVisible="{Binding IsRefinerSelectionEnabled}"
Theme="{StaticResource BetterComboBoxHybridModelTheme}"
HorizontalAlignment="Stretch"
ItemsSource="{Binding ClientManager.Models}"
SelectedItem="{Binding SelectedRefiner}" />
SelectedItem="{Binding SelectedRefiner}"/>
<!-- VAE -->
<TextBlock
Grid.Row="2"
Grid.Column="0"
MinWidth="60"
Margin="0,8,0,0"
VerticalAlignment="Center"
IsVisible="{Binding IsVaeSelectionEnabled}"
@ -191,7 +105,32 @@
ItemsSource="{Binding ClientManager.VaeModels}"
SelectedItem="{Binding SelectedVae}" />
</Grid>
<!-- CLIP Skip -->
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="0,8,0,0"
VerticalAlignment="Center"
IsVisible="{Binding IsClipSkipEnabled}"
Text="{x:Static lang:Resources.Label_CLIPSkip}"
TextAlignment="Left" />
<NumericUpDown
Grid.Row="3"
Grid.Column="1"
Grid.ColumnSpan="2"
IsVisible="{Binding IsClipSkipEnabled}"
Watermark="1"
HorizontalAlignment="Stretch"
Margin="0,8,0,0"
Minimum="1"
Maximum="24"
Increment="1"
ParsingNumberStyle="Integer"
Value="{Binding ClipSkip, Converter={x:Static converters:NullableDefaultNumericConverters.IntToDecimal}}"
ClipValueToMinMax="True"/>
</sg:SpacedGrid>
</controls:Card>
</ControlTemplate>
</Setter>

122
StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml

@ -0,0 +1,122 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
x:DataType="video:SvdImgToVidConditioningViewModel"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:video="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference.Video"
xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages">
<Design.PreviewWith>
<Grid MinWidth="400">
<controls:VideoGenerationSettingsCard DataContext="{x:Static mocks:DesignData.SvdImgToVidConditioningViewModel}" />
</Grid>
</Design.PreviewWith>
<Style Selector="controls|VideoGenerationSettingsCard">
<!-- Set Defaults -->
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Template">
<ControlTemplate>
<controls:Card Padding="8" HorizontalAlignment="{TemplateBinding HorizontalAlignment}">
<Grid Margin="4" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto, *">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_Frames}" />
<controls1:NumberBox
Grid.Row="0"
Grid.Column="1"
SelectionHighlightColor="Transparent"
Value="{Binding NumFrames}"
Margin="8,0,0,0"
SimpleNumberFormat="F0"
SmallChange="1"
HorizontalAlignment="Stretch"
SpinButtonPlacementMode="Inline"/>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_Fps}" />
<controls1:NumberBox
Grid.Row="1"
Grid.Column="1"
SelectionHighlightColor="Transparent"
Value="{Binding Fps}"
Margin="8,8,0,0"
SimpleNumberFormat="F0"
SmallChange="1"
HorizontalAlignment="Stretch"
SpinButtonPlacementMode="Inline"/>
<TextBlock
Grid.Row="2"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_MinCfg}" />
<controls1:NumberBox
Margin="8,8,0,0"
Grid.Row="2"
Grid.Column="1"
SelectionHighlightColor="Transparent"
Value="{Binding MinCfg}"
SimpleNumberFormat="F0"
SmallChange="1"
HorizontalAlignment="Stretch"
SpinButtonPlacementMode="Inline"/>
<TextBlock
Margin="0,8,8,0"
Grid.Row="3"
Grid.Column="0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_MotionBucketId}" />
<controls1:NumberBox
Margin="8,8,0,0"
Grid.Row="3"
Grid.Column="1"
SelectionHighlightColor="Transparent"
Value="{Binding MotionBucketId}"
SimpleNumberFormat="F0"
SmallChange="1"
HorizontalAlignment="Stretch"
SpinButtonPlacementMode="Inline"/>
<StackPanel Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="4"
Margin="0,16,0,0">
<Grid ColumnDefinitions="*,Auto">
<TextBlock
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_AugmentationLevel}" />
<controls1:NumberBox
Grid.Column="1"
Margin="4,0,0,0"
ValidationMode="InvalidInputOverwritten"
SmallChange="0.01"
SimpleNumberFormat="F2"
Value="{Binding AugmentationLevel}"
HorizontalAlignment="Stretch"
MinWidth="100"
SpinButtonPlacementMode="Compact"/>
</Grid>
<Slider
Minimum="0"
Maximum="10"
Value="{Binding AugmentationLevel}"
TickFrequency="1"
Margin="0,0,0,-4"
TickPlacement="BottomRight"/>
</StackPanel>
</Grid>
</controls:Card>
</ControlTemplate>
</Setter>
</Style>
</Styles>

7
StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs

@ -0,0 +1,7 @@
using Avalonia.Controls.Primitives;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Controls;
[Transient]
public class VideoGenerationSettingsCard : TemplatedControl { }

98
StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml

@ -0,0 +1,98 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
x:DataType="video:VideoOutputSettingsCardViewModel"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:video="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference.Video"
xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages">
<Design.PreviewWith>
<Grid MinWidth="400">
<controls:VideoOutputSettingsCard
DataContext="{x:Static mocks:DesignData.SvdImgToVidConditioningViewModel}" />
</Grid>
</Design.PreviewWith>
<Style Selector="controls|VideoOutputSettingsCard">
<!-- Set Defaults -->
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Template">
<ControlTemplate>
<controls:Card Padding="8" HorizontalAlignment="{TemplateBinding HorizontalAlignment}">
<Grid Margin="4" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto, *">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="2"
Text="Video Output Settings"
FontSize="16"
FontWeight="DemiBold"
Margin="0,0,0,16"
/>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_Fps}" />
<controls1:NumberBox
Grid.Row="1"
Grid.Column="1"
SelectionHighlightColor="Transparent"
Value="{Binding Fps}"
Margin="8,0,0,0"
SimpleNumberFormat="F0"
SmallChange="1"
HorizontalAlignment="Stretch"
SpinButtonPlacementMode="Inline" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_Lossless}" />
<CheckBox
Grid.Row="2"
Grid.Column="1"
IsChecked="{Binding Lossless}"
Margin="8,8,0,0"
HorizontalAlignment="Stretch" />
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_VideoQuality}" />
<controls1:NumberBox
Margin="8,8,0,0"
Grid.Row="3"
Grid.Column="1"
SelectionHighlightColor="Transparent"
Value="{Binding Quality}"
SimpleNumberFormat="F0"
SmallChange="1"
Maximum="100"
HorizontalAlignment="Stretch"
SpinButtonPlacementMode="Inline" />
<TextBlock
Margin="0,8,8,0"
Grid.Row="4"
Grid.Column="0"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Label_VideoOutputMethod}" />
<ComboBox
Grid.Row="4"
Grid.Column="1"
Margin="8,8,0,0"
MinWidth="100"
ItemsSource="{Binding AvailableMethods}"
SelectedIndex="{Binding SelectedMethod}" />
</Grid>
</controls:Card>
</ControlTemplate>
</Setter>
</Style>
</Styles>

7
StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs

@ -0,0 +1,7 @@
using Avalonia.Controls.Primitives;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Controls;
[Transient]
public class VideoOutputSettingsCard : TemplatedControl { }

137
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -4,15 +4,14 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using AvaloniaEdit.Utils;
using CommunityToolkit.Mvvm.ComponentModel;
using DynamicData;
using DynamicData.Binding;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Semver;
using StabilityMatrix.Avalonia.Controls.CodeCompletion;
using StabilityMatrix.Avalonia.Models;
@ -24,8 +23,9 @@ using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.Inference;
using StabilityMatrix.Avalonia.ViewModels.Inference.Modules;
using StabilityMatrix.Avalonia.ViewModels.Inference.Video;
using StabilityMatrix.Avalonia.ViewModels.OutputsPage;
using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Avalonia.ViewModels.Progress;
using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Core.Api;
@ -175,12 +175,23 @@ public static class DesignData
};
LaunchOptionsViewModel.UpdateFilterCards();
InstallerViewModel = Services.GetRequiredService<InstallerViewModel>();
InstallerViewModel.AvailablePackages = new ObservableCollectionExtended<BasePackage>(
packageFactory.GetAllAvailablePackages()
NewInstallerDialogViewModel = Services.GetRequiredService<PackageInstallBrowserViewModel>();
// NewInstallerDialogViewModel.InferencePackages = new ObservableCollectionExtended<BasePackage>(
// packageFactory.GetPackagesByType(PackageType.SdInference).OrderBy(p => p.InstallerSortOrder)
// );
// NewInstallerDialogViewModel.TrainingPackages = new ObservableCollection<BasePackage>(
// packageFactory.GetPackagesByType(PackageType.SdTraining).OrderBy(p => p.InstallerSortOrder)
// );
PackageInstallDetailViewModel = new PackageInstallDetailViewModel(
packageFactory.GetAllAvailablePackages().FirstOrDefault() as BaseGitPackage,
settingsManager,
notificationService,
null,
null,
null,
null
);
InstallerViewModel.SelectedPackage = InstallerViewModel.AvailablePackages[0];
InstallerViewModel.ReleaseNotes = "## Release Notes\nThis is a test release note.";
ObservableCacheEx.AddOrUpdate(
CheckpointsPageViewModel.CheckpointFoldersCache,
@ -373,13 +384,15 @@ public static class DesignData
new() { FilePath = "~/Models/Lora/model.safetensors", Title = "Some model" }
};
ProgressManagerViewModel
.ProgressItems
.AddRange(
ProgressManagerViewModel.ProgressItems.AddRange(
new ProgressItemViewModelBase[]
{
new ProgressItemViewModel(
new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressReport(0.5f, "Downloading..."))
new ProgressItem(
Guid.NewGuid(),
"Test File.exe",
new ProgressReport(0.5f, "Downloading...")
)
),
new MockDownloadProgressItemViewModel(
"Very Long Test File Name Need Even More Longness Thanks That's pRobably good 2.exe"
@ -402,7 +415,10 @@ public static class DesignData
}
[NotNull]
public static InstallerViewModel? InstallerViewModel { get; private set; }
public static PackageInstallBrowserViewModel? NewInstallerDialogViewModel { get; private set; }
[NotNull]
public static PackageInstallDetailViewModel? PackageInstallDetailViewModel { get; private set; }
[NotNull]
public static LaunchOptionsViewModel? LaunchOptionsViewModel { get; private set; }
@ -413,12 +429,14 @@ public static class DesignData
public static ServiceManager<ViewModelBase> DialogFactory =>
Services.GetRequiredService<ServiceManager<ViewModelBase>>();
public static MainWindowViewModel MainWindowViewModel => Services.GetRequiredService<MainWindowViewModel>();
public static MainWindowViewModel MainWindowViewModel =>
Services.GetRequiredService<MainWindowViewModel>();
public static FirstLaunchSetupViewModel FirstLaunchSetupViewModel =>
Services.GetRequiredService<FirstLaunchSetupViewModel>();
public static LaunchPageViewModel LaunchPageViewModel => Services.GetRequiredService<LaunchPageViewModel>();
public static LaunchPageViewModel LaunchPageViewModel =>
Services.GetRequiredService<LaunchPageViewModel>();
public static HuggingFacePageViewModel HuggingFacePageViewModel =>
Services.GetRequiredService<HuggingFacePageViewModel>();
@ -452,7 +470,10 @@ public static class DesignData
vm.SetPackages(settings.Settings.InstalledPackages);
vm.SetUnknownPackages(
new InstalledPackage[] { UnknownInstalledPackage.FromDirectoryName("sd-unknown-with-long-name"), }
new InstalledPackage[]
{
UnknownInstalledPackage.FromDirectoryName("sd-unknown-with-long-name"),
}
);
vm.PackageCards[0].IsUpdateAvailable = true;
@ -469,10 +490,14 @@ public static class DesignData
public static SettingsViewModel SettingsViewModel => Services.GetRequiredService<SettingsViewModel>();
public static NewPackageManagerViewModel NewPackageManagerViewModel =>
Services.GetRequiredService<NewPackageManagerViewModel>();
public static InferenceSettingsViewModel InferenceSettingsViewModel =>
Services.GetRequiredService<InferenceSettingsViewModel>();
public static MainSettingsViewModel MainSettingsViewModel => Services.GetRequiredService<MainSettingsViewModel>();
public static MainSettingsViewModel MainSettingsViewModel =>
Services.GetRequiredService<MainSettingsViewModel>();
public static AccountSettingsViewModel AccountSettingsViewModel =>
Services.GetRequiredService<AccountSettingsViewModel>();
@ -556,7 +581,10 @@ The gallery images are often inpainted, but you will get something very similar
}
}
};
var sampleViewModel = new ModelVersionViewModel(new HashSet<string> { "ABCD" }, sampleCivitVersions[0]);
var sampleViewModel = new ModelVersionViewModel(
new HashSet<string> { "ABCD" },
sampleCivitVersions[0]
);
// Sample data for dialogs
vm.Versions = new List<ModelVersionViewModel> { sampleViewModel };
@ -630,13 +658,22 @@ The gallery images are often inpainted, but you will get something very similar
vm.OutputProgress.Text = "Sampler 10/30";
});
public static InferenceImageToVideoViewModel InferenceImageToVideoViewModel =>
DialogFactory.Get<InferenceImageToVideoViewModel>(vm =>
{
vm.OutputProgress.Value = 10;
vm.OutputProgress.Maximum = 30;
vm.OutputProgress.Text = "Sampler 10/30";
});
public static InferenceImageToImageViewModel InferenceImageToImageViewModel =>
DialogFactory.Get<InferenceImageToImageViewModel>();
public static InferenceImageUpscaleViewModel InferenceImageUpscaleViewModel =>
DialogFactory.Get<InferenceImageUpscaleViewModel>();
public static PackageImportViewModel PackageImportViewModel => DialogFactory.Get<PackageImportViewModel>();
public static PackageImportViewModel PackageImportViewModel =>
DialogFactory.Get<PackageImportViewModel>();
public static RefreshBadgeViewModel RefreshBadgeViewModel => new() { State = ProgressState.Success };
@ -652,6 +689,8 @@ The gallery images are often inpainted, but you will get something very similar
});
public static SeedCardViewModel SeedCardViewModel => new();
public static SvdImgToVidConditioningViewModel SvdImgToVidConditioningViewModel => new();
public static VideoOutputSettingsCardViewModel VideoOutputSettingsCardViewModel => new();
public static SamplerCardViewModel SamplerCardViewModel =>
DialogFactory.Get<SamplerCardViewModel>(vm =>
@ -681,6 +720,9 @@ The gallery images are often inpainted, but you will get something very similar
public static ModelCardViewModel ModelCardViewModel => DialogFactory.Get<ModelCardViewModel>();
public static ImgToVidModelCardViewModel ImgToVidModelCardViewModel =>
DialogFactory.Get<ImgToVidModelCardViewModel>();
public static ImageGalleryCardViewModel ImageGalleryCardViewModel =>
DialogFactory.Get<ImageGalleryCardViewModel>(vm =>
{
@ -706,7 +748,8 @@ The gallery images are often inpainted, but you will get something very similar
);
});
public static ImageFolderCardViewModel ImageFolderCardViewModel => DialogFactory.Get<ImageFolderCardViewModel>();
public static ImageFolderCardViewModel ImageFolderCardViewModel =>
DialogFactory.Get<ImageFolderCardViewModel>();
public static FreeUCardViewModel FreeUCardViewModel => DialogFactory.Get<FreeUCardViewModel>();
@ -759,7 +802,8 @@ The gallery images are often inpainted, but you will get something very similar
public static UpscalerCardViewModel UpscalerCardViewModel => DialogFactory.Get<UpscalerCardViewModel>();
public static BatchSizeCardViewModel BatchSizeCardViewModel => DialogFactory.Get<BatchSizeCardViewModel>();
public static BatchSizeCardViewModel BatchSizeCardViewModel =>
DialogFactory.Get<BatchSizeCardViewModel>();
public static BatchSizeCardViewModel BatchSizeCardViewModelWithIndexOption =>
DialogFactory.Get<BatchSizeCardViewModel>(vm =>
@ -790,6 +834,42 @@ The gallery images are often inpainted, but you will get something very similar
}
}
public static IEnumerable<HybridModelFile> SampleHybridModels { get; } =
[
HybridModelFile.FromLocal(
new LocalModelFile
{
SharedFolderType = SharedFolderType.StableDiffusion,
RelativePath = "art_shaper_v8.safetensors",
PreviewImageFullPath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/dd9b038c-bd15-43ab-86ab-66e145ad7ff2/width=512",
ConnectedModelInfo = new ConnectedModelInfo
{
ModelName = "Art Shaper (very long name example)",
VersionName = "Style v8 (very long name)"
}
}
),
HybridModelFile.FromLocal(
new LocalModelFile
{
SharedFolderType = SharedFolderType.StableDiffusion,
RelativePath = "background_arts.safetensors",
PreviewImageFullPath =
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/71c81ddf-d8c3-46b4-843d-9f8f20a9254a/width=512",
ConnectedModelInfo = new ConnectedModelInfo
{
ModelName = "Background Arts",
VersionName = "Anime Style v10"
}
}
),
HybridModelFile.FromRemote("v1-5-pruned-emaonly.safetensors"),
HybridModelFile.FromRemote("sample-model.pt"),
];
public static HybridModelFile SampleHybridModel => SampleHybridModels.First();
public static ImageViewerViewModel ImageViewerViewModel =>
DialogFactory.Get<ImageViewerViewModel>(vm =>
{
@ -816,7 +896,8 @@ The gallery images are often inpainted, but you will get something very similar
public static InferenceConnectionHelpViewModel InferenceConnectionHelpViewModel =>
DialogFactory.Get<InferenceConnectionHelpViewModel>();
public static SelectImageCardViewModel SelectImageCardViewModel => DialogFactory.Get<SelectImageCardViewModel>();
public static SelectImageCardViewModel SelectImageCardViewModel =>
DialogFactory.Get<SelectImageCardViewModel>();
public static SelectImageCardViewModel SelectImageCardViewModel_WithImage =>
DialogFactory.Get<SelectImageCardViewModel>(vm =>
@ -829,12 +910,17 @@ The gallery images are often inpainted, but you will get something very similar
});
public static ImageSource SampleImageSource =>
new(new Uri("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1500"))
new(
new Uri(
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1500"
)
)
{
Label = "Test Image"
};
public static ControlNetCardViewModel ControlNetCardViewModel => DialogFactory.Get<ControlNetCardViewModel>();
public static ControlNetCardViewModel ControlNetCardViewModel =>
DialogFactory.Get<ControlNetCardViewModel>();
public static Indexer Types { get; } = new();
@ -844,7 +930,8 @@ The gallery images are often inpainted, but you will get something very similar
{
get
{
var type = Type.GetType(typeName) ?? throw new ArgumentException($"Type {typeName} not found");
var type =
Type.GetType(typeName) ?? throw new ArgumentException($"Type {typeName} not found");
try
{
return Services.GetService(type);

17
StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
@ -25,9 +26,21 @@ public class MockModelIndexService : IModelIndexService
}
/// <inheritdoc />
public Task<IReadOnlyList<LocalModelFile>> GetModelsOfType(SharedFolderType type)
public Task<IEnumerable<LocalModelFile>> FindAsync(SharedFolderType type)
{
return Task.FromResult<IReadOnlyList<LocalModelFile>>(new List<LocalModelFile>());
return Task.FromResult(Enumerable.Empty<LocalModelFile>());
}
/// <inheritdoc />
public Task<IEnumerable<LocalModelFile>> FindByHashAsync(string hashBlake3)
{
return Task.FromResult(Enumerable.Empty<LocalModelFile>());
}
/// <inheritdoc />
public Task<bool> RemoveModelAsync(LocalModelFile model)
{
return Task.FromResult(false);
}
/// <inheritdoc />

47
StabilityMatrix.Avalonia/DialogHelper.cs

@ -23,15 +23,15 @@ using NLog;
using Refit;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Services;
using TextMateSharp.Grammars;
using Process = FuzzySharp.Process;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Core.Helper;
namespace StabilityMatrix.Avalonia;
@ -48,11 +48,7 @@ public static class DialogHelper
IReadOnlyList<TextBoxField> textFields
)
{
return CreateTextEntryDialog(
title,
new MarkdownScrollViewer { Markdown = description },
textFields
);
return CreateTextEntryDialog(title, new MarkdownScrollViewer { Markdown = description }, textFields);
}
/// <summary>
@ -80,11 +76,7 @@ public static class DialogHelper
var grid = new Grid
{
RowDefinitions =
{
new RowDefinition(GridLength.Star),
new RowDefinition(GridLength.Auto)
},
RowDefinitions = { new RowDefinition(GridLength.Star), new RowDefinition(GridLength.Auto) },
Children = { markdown, image }
};
@ -109,18 +101,14 @@ public static class DialogHelper
var grid = new Grid
{
RowDefinitions =
{
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Star)
},
RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star) },
Children = { content, stackPanel }
};
grid.Loaded += (_, _) =>
{
// Focus first TextBox
var firstTextBox = stackPanel.Children
.OfType<StackPanel>()
var firstTextBox = stackPanel
.Children.OfType<StackPanel>()
.FirstOrDefault()
.FindDescendantOfType<TextBox>();
firstTextBox!.Focus();
@ -254,16 +242,16 @@ public static class DialogHelper
Content = viewer,
CloseButtonText = Resources.Action_Close,
IsPrimaryButtonEnabled = false,
MinDialogWidth = 800,
MaxDialogHeight = 1000,
MaxDialogWidth = 1000
};
}
/// <summary>
/// Create a dialog for displaying an ApiException
/// </summary>
public static BetterContentDialog CreateApiExceptionDialog(
ApiException exception,
string? title = null
)
public static BetterContentDialog CreateApiExceptionDialog(ApiException exception, string? title = null)
{
Dispatcher.UIThread.VerifyAccess();
@ -275,9 +263,7 @@ public static class DialogHelper
Options = { ShowColumnRulers = false, AllowScrollBelowDocument = false }
};
var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
textEditor
.InstallTextMate(registryOptions)
.SetGrammar(registryOptions.GetScopeByLanguageId("json"));
textEditor.InstallTextMate(registryOptions).SetGrammar(registryOptions.GetScopeByLanguageId("json"));
var mainGrid = new StackPanel
{
@ -354,9 +340,7 @@ public static class DialogHelper
Options = { ShowColumnRulers = false, AllowScrollBelowDocument = false }
};
var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
textEditor
.InstallTextMate(registryOptions)
.SetGrammar(registryOptions.GetScopeByLanguageId("json"));
textEditor.InstallTextMate(registryOptions).SetGrammar(registryOptions.GetScopeByLanguageId("json"));
var mainGrid = new StackPanel
{
@ -437,8 +421,7 @@ public static class DialogHelper
{
Dispatcher.UIThread.VerifyAccess();
var title =
exception is PromptSyntaxError ? "Prompt Syntax Error" : "Prompt Validation Error";
var title = exception is PromptSyntaxError ? "Prompt Syntax Error" : "Prompt Validation Error";
// Get the index of the error
var errorIndex = exception.TextOffset;

35
StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs

@ -20,7 +20,9 @@ public static class ComfyNodeBuilderExtensions
int? batchIndex = null
)
{
var emptyLatent = builder.Nodes.AddTypedNode(
var emptyLatent = builder
.Nodes
.AddTypedNode(
new ComfyNodeBuilder.EmptyLatentImage
{
Name = "EmptyLatentImage",
@ -36,7 +38,8 @@ public static class ComfyNodeBuilderExtensions
// If batch index is selected, add a LatentFromBatch
if (batchIndex is not null)
{
builder.Connections.Primary = builder.Nodes
builder.Connections.Primary = builder
.Nodes
.AddNamedNode(
ComfyNodeBuilder.LatentFromBatch(
"LatentFromBatch",
@ -64,9 +67,9 @@ public static class ComfyNodeBuilderExtensions
var sourceImageRelativePath = Path.Combine("Inference", image.GetHashGuidFileNameCached());
// Load source
var loadImage = builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.LoadImage { Name = "LoadImage", Image = sourceImageRelativePath }
);
var loadImage = builder
.Nodes
.AddTypedNode(new ComfyNodeBuilder.LoadImage { Name = "LoadImage", Image = sourceImageRelativePath });
builder.Connections.Primary = loadImage.Output1;
builder.Connections.PrimarySize = imageSize;
@ -74,7 +77,8 @@ public static class ComfyNodeBuilderExtensions
// If batch index is selected, add a LatentFromBatch
if (batchIndex is not null)
{
builder.Connections.Primary = builder.Nodes
builder.Connections.Primary = builder
.Nodes
.AddNamedNode(
ComfyNodeBuilder.LatentFromBatch(
"LatentFromBatch",
@ -93,23 +97,24 @@ public static class ComfyNodeBuilderExtensions
if (builder.Connections.Primary is null)
throw new ArgumentException("No Primary");
var image = builder.Connections.Primary.Match(
var image = builder
.Connections
.Primary
.Match(
_ =>
builder.GetPrimaryAsImage(
builder.Connections.PrimaryVAE
?? builder.Connections.RefinerVAE
?? builder.Connections.BaseVAE
?? builder.Connections.Refiner.VAE
?? builder.Connections.Base.VAE
?? throw new ArgumentException("No Primary, Refiner, or Base VAE")
),
image => image
);
var previewImage = builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.PreviewImage
{
Name = builder.Nodes.GetUniqueName("SaveImage"),
Images = image
}
var previewImage = builder
.Nodes
.AddTypedNode(
new ComfyNodeBuilder.PreviewImage { Name = builder.Nodes.GetUniqueName("SaveImage"), Images = image }
);
builder.Connections.OutputNodes.Add(previewImage);

17
StabilityMatrix.Avalonia/Helpers/UriHandler.cs

@ -58,17 +58,19 @@ public class UriHandler
public void RegisterUriScheme()
{
// Not supported on macos
if (Compat.IsWindows)
{
RegisterUriSchemeWin();
}
else
else if (Compat.IsLinux)
{
// Try to register on unix but ignore errors
// Try to register on linux but ignore errors
// Library does not support some distros
try
{
RegisterUriSchemeUnix();
RegisterUriSchemeLinux();
}
catch (Exception e)
{
@ -98,9 +100,14 @@ public class UriHandler
}
}
private void RegisterUriSchemeUnix()
[SupportedOSPlatform("linux")]
private void RegisterUriSchemeLinux()
{
var service = URISchemeServiceFactory.GetURISchemeSerivce(Scheme, Description, Compat.AppCurrentPath.FullPath);
var service = URISchemeServiceFactory.GetURISchemeSerivce(
Scheme,
Description,
Compat.AppCurrentPath.FullPath
);
service.Set();
}
}

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

@ -707,6 +707,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Augmentation Level.
/// </summary>
public static string Label_AugmentationLevel {
get {
return ResourceManager.GetString("Label_AugmentationLevel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Auto Completion.
/// </summary>
@ -833,6 +842,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to CLIP Skip.
/// </summary>
public static string Label_CLIPSkip {
get {
return ResourceManager.GetString("Label_CLIPSkip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Close dialog when finished.
/// </summary>
@ -1184,6 +1202,24 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Frames Per Second.
/// </summary>
public static string Label_Fps {
get {
return ResourceManager.GetString("Label_Fps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Frames.
/// </summary>
public static string Label_Frames {
get {
return ResourceManager.GetString("Label_Frames", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to General.
/// </summary>
@ -1229,6 +1265,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Image to Video.
/// </summary>
public static string Label_ImageToVideo {
get {
return ResourceManager.GetString("Label_ImageToVideo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Image Viewer.
/// </summary>
@ -1427,6 +1472,24 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Lossless.
/// </summary>
public static string Label_Lossless {
get {
return ResourceManager.GetString("Label_Lossless", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Min CFG.
/// </summary>
public static string Label_MinCfg {
get {
return ResourceManager.GetString("Label_MinCfg", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Missing Image File.
/// </summary>
@ -1481,6 +1544,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Motion Bucket ID.
/// </summary>
public static string Label_MotionBucketId {
get {
return ResourceManager.GetString("Label_MotionBucketId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Networks (Lora / LyCORIS).
/// </summary>
@ -2156,6 +2228,24 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Method.
/// </summary>
public static string Label_VideoOutputMethod {
get {
return ResourceManager.GetString("Label_VideoOutputMethod", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Quality.
/// </summary>
public static string Label_VideoQuality {
get {
return ResourceManager.GetString("Label_VideoQuality", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Waiting to connect....
/// </summary>

30
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -897,4 +897,34 @@
<data name="Label_HolidayMode" xml:space="preserve">
<value>Holiday Mode</value>
</data>
<data name="Label_CLIPSkip" xml:space="preserve">
<value>CLIP Skip</value>
</data>
<data name="Label_ImageToVideo" xml:space="preserve">
<value>Image to Video</value>
</data>
<data name="Label_Fps" xml:space="preserve">
<value>Frames Per Second</value>
</data>
<data name="Label_MinCfg" xml:space="preserve">
<value>Min CFG</value>
</data>
<data name="Label_Lossless" xml:space="preserve">
<value>Lossless</value>
</data>
<data name="Label_Frames" xml:space="preserve">
<value>Frames</value>
</data>
<data name="Label_MotionBucketId" xml:space="preserve">
<value>Motion Bucket ID</value>
</data>
<data name="Label_AugmentationLevel" xml:space="preserve">
<value>Augmentation Level</value>
</data>
<data name="Label_VideoOutputMethod" xml:space="preserve">
<value>Method</value>
</data>
<data name="Label_VideoQuality" xml:space="preserve">
<value>Quality</value>
</data>
</root>

33
StabilityMatrix.Avalonia/Models/CommandItem.cs

@ -0,0 +1,33 @@
using System.Diagnostics.Contracts;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Windows.Input;
using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Avalonia.Models;
public partial record CommandItem
{
public ICommand Command { get; init; }
public string DisplayName { get; init; }
public CommandItem(ICommand command, [CallerArgumentExpression("command")] string? commandName = null)
{
Command = command;
DisplayName = commandName == null ? "" : ProcessName(commandName);
}
[Pure]
private static string ProcessName(string name)
{
name = name.StripEnd("Command");
name = SpaceTitleCaseRegex().Replace(name, "$1 $2");
return name;
}
[GeneratedRegex("([a-z])_?([A-Z])")]
private static partial Regex SpaceTitleCaseRegex();
}

11
StabilityMatrix.Avalonia/Models/ITemplateKey.cs

@ -0,0 +1,11 @@
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Models;
/// <summary>
/// Implements a template key for <see cref="DataTemplateSelector"/>
/// </summary>
public interface ITemplateKey<out T>
{
T TemplateKey { get; }
}

19
StabilityMatrix.Avalonia/Models/ImageSource.cs

@ -12,7 +12,7 @@ using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Avalonia.Models;
public record ImageSource : IDisposable
public record ImageSource : IDisposable, ITemplateKey<ImageSourceTemplateType>
{
private Hash? contentHashBlake3;
@ -55,6 +55,23 @@ public record ImageSource : IDisposable
Bitmap = bitmap;
}
/// <inheritdoc />
public ImageSourceTemplateType TemplateKey
{
get
{
var ext = LocalFile?.Extension ?? Path.GetExtension(RemoteUrl?.ToString());
if (ext is not null && ext.Equals(".webp", StringComparison.OrdinalIgnoreCase))
{
// TODO: Check if webp is animated
return ImageSourceTemplateType.WebpAnimation;
}
return ImageSourceTemplateType.Image;
}
}
[JsonIgnore]
public Task<Bitmap?> BitmapAsync => GetBitmapAsync();

8
StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs

@ -0,0 +1,8 @@
namespace StabilityMatrix.Avalonia.Models;
public enum ImageSourceTemplateType
{
Default,
Image,
WebpAnimation
}

15
StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
namespace StabilityMatrix.Avalonia.Models.Inference;
@ -29,26 +29,19 @@ public class ModuleApplyStepEventArgs : EventArgs
/// <summary>
/// Generation overrides (like hires fix generate, current seed generate, etc.)
/// </summary>
public IReadOnlyDictionary<Type, bool> IsEnabledOverrides { get; init; } =
new Dictionary<Type, bool>();
public IReadOnlyDictionary<Type, bool> IsEnabledOverrides { get; init; } = new Dictionary<Type, bool>();
public class ModuleApplyStepTemporaryArgs
{
/// <summary>
/// Temporary conditioning apply step, used by samplers to apply control net.
/// </summary>
public (
ConditioningNodeConnection Positive,
ConditioningNodeConnection Negative
)? Conditioning { get; set; }
public ConditioningConnections? Conditioning { get; set; }
/// <summary>
/// Temporary refiner conditioning apply step, used by samplers to apply control net.
/// </summary>
public (
ConditioningNodeConnection Positive,
ConditioningNodeConnection Negative
)? RefinerConditioning { get; set; }
public ConditioningConnections? RefinerConditioning { get; set; }
/// <summary>
/// Temporary model apply step, used by samplers to apply control net.

11
StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs

@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace StabilityMatrix.Avalonia.Models.Inference;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum VideoOutputMethod
{
Fastest,
Default,
Slowest,
}

2
StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs

@ -33,7 +33,7 @@ public class InferenceProjectDocument : ICloneable
InferenceImageToImageViewModel => InferenceProjectType.ImageToImage,
InferenceTextToImageViewModel => InferenceProjectType.TextToImage,
InferenceImageUpscaleViewModel => InferenceProjectType.Upscale,
_ => throw new InvalidOperationException($"Unknown loadable model type: {loadableModel.GetType()}")
InferenceImageToVideoViewModel => InferenceProjectType.ImageToVideo,
},
State = loadableModel.SaveStateToJsonObject()
};

4
StabilityMatrix.Avalonia/Models/InferenceProjectType.cs

@ -9,7 +9,8 @@ public enum InferenceProjectType
TextToImage,
ImageToImage,
Inpainting,
Upscale
Upscale,
ImageToVideo
}
public static class InferenceProjectTypeExtensions
@ -22,6 +23,7 @@ public static class InferenceProjectTypeExtensions
InferenceProjectType.ImageToImage => typeof(InferenceImageToImageViewModel),
InferenceProjectType.Inpainting => null,
InferenceProjectType.Upscale => typeof(InferenceImageUpscaleViewModel),
InferenceProjectType.ImageToVideo => typeof(InferenceImageToVideoViewModel),
InferenceProjectType.Unknown => null,
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
};

10
StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs

@ -0,0 +1,10 @@
using StabilityMatrix.Core.Models.Packages;
namespace StabilityMatrix.Avalonia.Models;
public record PackageManagerNavigationOptions
{
public bool OpenInstallerDialog { get; init; }
public BasePackage? InstallerSelectedPackage { get; init; }
}

7
StabilityMatrix.Avalonia/Services/INavigationService.cs

@ -19,10 +19,7 @@ public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypePar
/// <summary>
/// Navigate to the view of the given view model type.
/// </summary>
void NavigateTo<TViewModel>(
NavigationTransitionInfo? transitionInfo = null,
object? param = null
)
void NavigateTo<TViewModel>(NavigationTransitionInfo? transitionInfo = null, object? param = null)
where TViewModel : ViewModelBase;
/// <summary>
@ -38,4 +35,6 @@ public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypePar
/// Navigate to the view of the given view model.
/// </summary>
void NavigateTo(ViewModelBase viewModel, NavigationTransitionInfo? transitionInfo = null);
bool GoBack();
}

48
StabilityMatrix.Avalonia/Services/NavigationService.cs

@ -7,7 +7,9 @@ using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.Services;
@ -20,6 +22,10 @@ namespace StabilityMatrix.Avalonia.Services;
ImplType = typeof(NavigationService<SettingsViewModel>),
InterfaceType = typeof(INavigationService<SettingsViewModel>)
)]
[Singleton(
ImplType = typeof(NavigationService<NewPackageManagerViewModel>),
InterfaceType = typeof(INavigationService<NewPackageManagerViewModel>)
)]
public class NavigationService<T> : INavigationService<T>
{
private Frame? _frame;
@ -33,10 +39,7 @@ public class NavigationService<T> : INavigationService<T>
}
/// <inheritdoc />
public void NavigateTo<TViewModel>(
NavigationTransitionInfo? transitionInfo = null,
object? param = null
)
public void NavigateTo<TViewModel>(NavigationTransitionInfo? transitionInfo = null, object? param = null)
where TViewModel : ViewModelBase
{
if (_frame is null)
@ -70,10 +73,7 @@ public class NavigationService<T> : INavigationService<T>
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) }
);
TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) });
}
/// <inheritdoc />
@ -120,10 +120,7 @@ public class NavigationService<T> : INavigationService<T>
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = viewModelType }
);
TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = viewModelType });
}
/// <inheritdoc />
@ -159,13 +156,36 @@ public class NavigationService<T> : INavigationService<T>
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = viewModel.GetType(), ViewModel = viewModel }
);
}
public bool GoBack()
{
if (_frame?.Content is IHandleNavigation navigationHandler)
{
var wentBack = navigationHandler.GoBack();
if (wentBack)
{
return true;
}
}
if (_frame is not { CanGoBack: true })
return false;
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs
{
ViewModelType = viewModel.GetType(),
ViewModel = viewModel
ViewModelType = _frame.BackStack.Last().SourcePageType,
ViewModel = _frame.BackStack.Last().Context
}
);
_frame.GoBack();
return true;
}
}

45
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -1,5 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<CFBundleName>Stability Matrix</CFBundleName>
<CFBundleIconFile>Assets/AppIcon.icns</CFBundleIconFile>
<CFBundlePackageType>APPL</CFBundlePackageType>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
@ -8,12 +11,19 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon>
<Version>2.7.0-pre.999</Version>
<Version>2.8.0-dev.999</Version>
<InformationalVersion>$(Version)</InformationalVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<CFBundleURLTypes Include="dummy">
<CFBundleURLName>StabilityMatrix.URL</CFBundleURLName>
<CFBundleURLSchemes>stabilitymatrix;stabilitymatrix://</CFBundleURLSchemes>
</CFBundleURLTypes>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StabilityMatrix.Tests" />
<InternalsVisibleTo Include="StabilityMatrix.UITests" />
@ -30,6 +40,7 @@
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
<PackageReference Include="Avalonia.HtmlRenderer" Version="11.0.0" />
<PackageReference Include="Avalonia.Labs.Controls" Version="11.0.2" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.5" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.5" />
<PackageReference Include="bodong.Avalonia.PropertyGrid" Version="11.0.5.1" />
@ -41,6 +52,7 @@
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.5" />
<PackageReference Include="Dock.Serializer" Version="11.0.0.5" />
<PackageReference Include="DynamicData" Version="8.3.27" />
<PackageReference Include="DotNet.Bundle" Version="0.9.13" />
<PackageReference Include="Exceptionless.DateTimeExtensions" Version="3.4.3" />
<PackageReference Include="FluentAvalonia.BreadcrumbBar" Version="2.0.2" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.4" />
@ -91,6 +103,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia.Gif\Avalonia.Gif.csproj" />
<ProjectReference Include="..\StabilityMatrix.Core\StabilityMatrix.Core.csproj" />
</ItemGroup>
@ -129,6 +142,12 @@
<AvaloniaResource Include="Assets\macos-arm64\**" Condition="'$(RuntimeIdentifier)' == 'osx-arm64'" />
</ItemGroup>
<ItemGroup>
<None Update="Assets\AppIcon.icns" Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Languages\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
@ -142,6 +161,30 @@
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Update="Views\Inference\InferenceImageToVideoView.axaml.cs">
<DependentUpon>InferenceImageToVideoView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Controls\VideoGenerationSettingsCard.axaml.cs">
<DependentUpon>VideoGenerationSettingsCard.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Controls\VideoOutputSettingsCard.axaml.cs">
<DependentUpon>VideoOutputSettingsCard.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\NewPackageManagerPage.axaml.cs">
<DependentUpon>NewPackageManagerPage.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\PackageManager\PackageInstallDetailView.axaml.cs">
<DependentUpon>PackageInstallDetailView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\PackageManager\PackageInstallBrowserView.axaml.cs">
<DependentUpon>NewInstallerDialog.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<!-- set HUSKY to 0 to disable, or opt-in during CI by setting HUSKY to 1 -->

254
StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml

@ -0,0 +1,254 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:mocks="using:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<Panel Width="450" Height="600">
<StackPanel Margin="8" Spacing="4" Width="250">
<controls:BetterComboBox
HorizontalAlignment="Stretch"
ItemsSource="{x:Static mocks:DesignData.SampleHybridModels}"
SelectedIndex="0" />
<controls:BetterComboBox
HorizontalAlignment="Stretch"
ItemsSource="{x:Static mocks:DesignData.SampleHybridModels}"
SelectedIndex="0"
Theme="{DynamicResource BetterComboBoxHybridModelTheme}" />
</StackPanel>
</Panel>
</Design.PreviewWith>
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<ControlTheme
x:Key="BetterComboBoxItemHybridModelTheme"
BasedOn="{StaticResource {x:Type ComboBoxItem}}"
TargetType="ComboBoxItem">
<Setter Property="ToolTip.Placement" Value="RightEdgeAlignedTop" />
<Setter Property="ToolTip.Tip">
<Template>
<sg:SpacedGrid
x:DataType="models:HybridModelFile"
ColumnDefinitions="Auto,*"
ColumnSpacing="6"
RowSpacing="0">
<!-- Image -->
<controls:BetterAdvancedImage
Width="64"
Height="96"
CornerRadius="6"
IsVisible="{Binding Local.PreviewImageFullPathGlobal, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=''}"
RenderOptions.BitmapInterpolationMode="HighQuality"
Source="{Binding Local.PreviewImageFullPathGlobal, FallbackValue=''}"
Stretch="UniformToFill"
StretchDirection="Both" />
<StackPanel
Grid.Column="1"
MaxWidth="300"
VerticalAlignment="Stretch">
<!-- Title -->
<TextBlock
Margin="0,0,0,4"
HorizontalAlignment="Left"
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsVisible="{Binding Local.ConnectedModelInfo, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}"
Text="{Binding Local.ConnectedModelInfo.ModelName, FallbackValue=''}"
TextWrapping="WrapWithOverflow" />
<!-- Version -->
<TextBlock
Margin="0,0,0,8"
HorizontalAlignment="Left"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
IsVisible="{Binding Local.ConnectedModelInfo, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}"
Text="{Binding Local.ConnectedModelInfo.VersionName, FallbackValue=''}"
TextWrapping="WrapWithOverflow" />
<!-- Path -->
<TextBlock
HorizontalAlignment="Left"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding FileName}"
TextWrapping="Wrap" />
</StackPanel>
</sg:SpacedGrid>
</Template>
</Setter>
</ControlTheme>
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<ControlTheme
x:Key="{x:Type controls:BetterComboBox}"
BasedOn="{StaticResource {x:Type ComboBox}}"
TargetType="controls:BetterComboBox" />
<ControlTheme
x:Key="BetterComboBoxHybridModelTheme"
BasedOn="{StaticResource {x:Type controls:BetterComboBox}}"
TargetType="controls:BetterComboBox">
<ControlTheme.Resources>
<controls:HybridModelTemplateSelector x:Key="HybridModelTemplateSelector">
<DataTemplate x:Key="{x:Static models:HybridModelType.Downloadable}" DataType="models:HybridModelFile">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Foreground="{DynamicResource ThemeGreyColor}" Text="{Binding ShortDisplayName}" />
<Button
Grid.Column="1"
Margin="8,0,0,0"
Padding="0"
Classes="transparent-full">
<fluentIcons:SymbolIcon
VerticalAlignment="Center"
FontSize="18"
Foreground="{DynamicResource ThemeGreyColor}"
IsFilled="True"
Symbol="CloudArrowDown" />
</Button>
</Grid>
</DataTemplate>
<DataTemplate x:Key="{x:Static models:HybridModelType.Local}" DataType="models:HybridModelFile">
<sg:SpacedGrid
HorizontalAlignment="Stretch"
ColumnDefinitions="Auto,*"
ColumnSpacing="8"
TextBlock.TextTrimming="CharacterEllipsis"
TextBlock.TextWrapping="NoWrap">
<controls:BetterAdvancedImage
Grid.RowSpan="2"
Width="42"
Height="42"
RenderOptions.BitmapInterpolationMode="HighQuality"
Source="{Binding Local.PreviewImageFullPathGlobal}"
Stretch="UniformToFill"
StretchDirection="Both">
<controls:BetterAdvancedImage.Clip>
<EllipseGeometry Rect="0,0,42,42" />
</controls:BetterAdvancedImage.Clip>
</controls:BetterAdvancedImage>
<!-- Text -->
<sg:SpacedGrid
Grid.Row="1"
Grid.Column="1"
RowDefinitions="Auto,Auto,Auto"
RowSpacing="1">
<TextBlock Text="{Binding Local.DisplayModelName}" TextTrimming="CharacterEllipsis" />
<TextBlock
Grid.Row="1"
FontSize="12"
FontWeight="Regular"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding Local.DisplayModelVersion}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Grid.Row="2"
FontSize="11"
FontWeight="Normal"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding Local.DisplayModelFileName}" />
</sg:SpacedGrid>
</sg:SpacedGrid>
</DataTemplate>
<DataTemplate x:Key="{x:Static models:HybridModelType.None}" DataType="models:HybridModelFile">
<StackPanel>
<TextBlock Text="{Binding ShortDisplayName}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</controls:HybridModelTemplateSelector>
<controls:HybridModelTemplateSelector x:Key="HybridModelSelectionBoxTemplateSelector">
<DataTemplate x:Key="{x:Static models:HybridModelType.Downloadable}" DataType="models:HybridModelFile">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Foreground="{DynamicResource ThemeGreyColor}" Text="{Binding ShortDisplayName}" />
<Button
Grid.Column="1"
Margin="8,0,0,0"
Padding="0"
Classes="transparent-full">
<fluentIcons:SymbolIcon
VerticalAlignment="Center"
FontSize="18"
Foreground="{DynamicResource ThemeGreyColor}"
IsFilled="True"
Symbol="CloudArrowDown" />
</Button>
</Grid>
</DataTemplate>
<DataTemplate x:Key="{x:Static models:HybridModelType.Local}" DataType="models:HybridModelFile">
<sg:SpacedGrid
HorizontalAlignment="Stretch"
ColumnDefinitions="Auto,*"
ColumnSpacing="8"
TextBlock.TextTrimming="CharacterEllipsis"
TextBlock.TextWrapping="NoWrap">
<controls:BetterAdvancedImage
Grid.RowSpan="2"
Width="36"
Height="36"
RenderOptions.BitmapInterpolationMode="HighQuality"
Source="{Binding Local.PreviewImageFullPathGlobal}"
Stretch="UniformToFill"
StretchDirection="Both">
<controls:BetterAdvancedImage.Clip>
<EllipseGeometry Rect="0,0,36,36" />
</controls:BetterAdvancedImage.Clip>
</controls:BetterAdvancedImage>
<!-- Text -->
<sg:SpacedGrid
Grid.Row="1"
Grid.Column="1"
RowDefinitions="Auto,Auto"
RowSpacing="1">
<TextBlock Text="{Binding Local.DisplayModelName}" TextTrimming="CharacterEllipsis" />
<TextBlock
Grid.Row="1"
FontSize="12"
FontWeight="Regular"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding Local.DisplayModelVersion}"
TextTrimming="CharacterEllipsis" />
</sg:SpacedGrid>
</sg:SpacedGrid>
</DataTemplate>
<DataTemplate x:Key="{x:Static models:HybridModelType.None}" DataType="models:HybridModelFile">
<StackPanel>
<TextBlock Text="{Binding ShortDisplayName}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</controls:HybridModelTemplateSelector>
</ControlTheme.Resources>
<Setter Property="TextBlock.TextWrapping" Value="NoWrap" />
<Setter Property="SelectionBoxItemTemplate" Value="{StaticResource HybridModelSelectionBoxTemplateSelector}" />
<Setter Property="ItemTemplate" Value="{StaticResource HybridModelTemplateSelector}" />
<Setter Property="ItemContainerTheme" Value="{StaticResource BetterComboBoxItemHybridModelTheme}" />
<Style Selector="^ /template/ Popup#PART_Popup">
<Setter Property="Width" Value="400" />
<Setter Property="Placement" Value="Bottom" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="Effect">
<DropShadowEffect
BlurRadius="32"
Opacity="0.6"
Color="#FF000000" />
</Setter>
</Style>
</ControlTheme>
</ResourceDictionary>

42
StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml

@ -2,11 +2,20 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:mocks="using:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<Panel Width="400" Height="600">
<Panel Width="350" Height="200" />
<Panel Width="250" Height="600">
<StackPanel Margin="8" Spacing="4">
<ui:FAComboBox
HorizontalAlignment="Stretch"
SelectedIndex="0"
Theme="{DynamicResource FAComboBoxHybridModelTheme}"
ItemsSource="{x:Static mocks:DesignData.SampleHybridModels}"/>
</StackPanel>
</Panel>
</Design.PreviewWith>
@ -15,20 +24,14 @@
x:Key="FAComboBoxItemHybridModelTheme"
BasedOn="{StaticResource {x:Type ui:FAComboBoxItem}}"
TargetType="ui:FAComboBoxItem">
<!--<Setter Property="ContentTemplate">
<DataTemplate DataType="models:HybridModelFile">
<StackPanel>
<TextBlock Text="{Binding ShortDisplayName}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</Setter>-->
<Setter Property="ToolTip.Placement" Value="RightEdgeAlignedTop" />
<Setter Property="ToolTip.Tip">
<Template>
<StackPanel
<sg:SpacedGrid
x:DataType="models:HybridModelFile"
Orientation="Horizontal"
Spacing="6">
ColumnDefinitions="Auto,*"
ColumnSpacing="6"
RowSpacing="0">
<!-- Image -->
<controls:BetterAdvancedImage
Width="64"
@ -39,10 +42,7 @@
Source="{Binding Local.PreviewImageFullPathGlobal, FallbackValue=''}"
Stretch="UniformToFill"
StretchDirection="Both" />
<Grid
MaxWidth="300"
VerticalAlignment="Stretch"
RowDefinitions="Auto,Auto,*">
<StackPanel Grid.Column="1" MaxWidth="300">
<!-- Title -->
<TextBlock
Margin="0,0,0,4"
@ -51,28 +51,26 @@
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsVisible="{Binding Local.ConnectedModelInfo, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}"
Text="{Binding Local.ConnectedModelInfo.ModelName, FallbackValue=''}"
Text="{Binding Local.DisplayModelName}"
TextWrapping="WrapWithOverflow" />
<!-- Version -->
<TextBlock
Grid.Row="1"
Margin="0,0,0,8"
HorizontalAlignment="Left"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
IsVisible="{Binding Local.ConnectedModelInfo, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}"
Text="{Binding Local.ConnectedModelInfo.VersionName, FallbackValue=''}"
Text="{Binding Local.DisplayModelVersion}"
TextWrapping="WrapWithOverflow" />
<!-- Path -->
<TextBlock
Grid.Row="2"
HorizontalAlignment="Left"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding FileName}"
TextWrapping="Wrap" />
</Grid>
</StackPanel>
</sg:SpacedGrid>
</Template>
</Setter>
</ControlTheme>
@ -88,7 +86,7 @@
</StackPanel>
</DataTemplate>
</Setter>
<Setter Property="ItemContainerTheme" Value="{StaticResource FAComboBoxItemHybridModelTheme}"/>
<Setter Property="ItemContainerTheme" Value="{StaticResource FAComboBoxItemHybridModelTheme}" />
</ControlTheme>
</ResourceDictionary>

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

@ -1,11 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Management;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@ -13,6 +14,8 @@ using AsyncAwaitBestPractices;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using ExifLibrary;
using MetadataExtractor.Formats.Exif;
using NLog;
using Refit;
using SkiaSharp;
@ -24,6 +27,7 @@ using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.Inference;
using StabilityMatrix.Avalonia.ViewModels.Inference.Modules;
using StabilityMatrix.Core.Animation;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
@ -42,7 +46,9 @@ namespace StabilityMatrix.Avalonia.ViewModels.Base;
/// This includes a progress reporter, image output view model, and generation virtual methods.
/// </summary>
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public abstract partial class InferenceGenerationViewModelBase : InferenceTabViewModelBase, IImageGalleryComponent
public abstract partial class InferenceGenerationViewModelBase
: InferenceTabViewModelBase,
IImageGalleryComponent
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
@ -91,13 +97,22 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
ImageGenerationEventArgs args,
int batchNum = 0,
int batchTotal = 0,
bool isGrid = false
bool isGrid = false,
string fileExtension = "png"
)
{
var defaultOutputDir = settingsManager.ImagesInferenceDirectory;
defaultOutputDir.Create();
return WriteOutputImageAsync(imageStream, defaultOutputDir, args, batchNum, batchTotal, isGrid);
return WriteOutputImageAsync(
imageStream,
defaultOutputDir,
args,
batchNum,
batchTotal,
isGrid,
fileExtension
);
}
/// <summary>
@ -109,7 +124,8 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
ImageGenerationEventArgs args,
int batchNum = 0,
int batchTotal = 0,
bool isGrid = false
bool isGrid = false,
string fileExtension = "png"
)
{
var formatTemplateStr = settingsManager.Settings.InferenceOutputImageFileNameFormat;
@ -128,7 +144,10 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
)
{
// Fallback to default
Logger.Warn("Failed to parse format template: {FormatTemplate}, using default", formatTemplateStr);
Logger.Warn(
"Failed to parse format template: {FormatTemplate}, using default",
formatTemplateStr
);
format = FileNameFormat.Parse(FileNameFormat.DefaultTemplate, formatProvider);
}
@ -144,7 +163,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
}
var fileName = format.GetFileName();
var file = outputDir.JoinFile($"{fileName}.png");
var file = outputDir.JoinFile($"{fileName}.{fileExtension}");
// Until the file is free, keep adding _{i} to the end
for (var i = 0; i < 100; i++)
@ -152,14 +171,14 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
if (!file.Exists)
break;
file = outputDir.JoinFile($"{fileName}_{i + 1}.png");
file = outputDir.JoinFile($"{fileName}_{i + 1}.{fileExtension}");
}
// If that fails, append an 7-char uuid
if (file.Exists)
{
var uuid = Guid.NewGuid().ToString("N")[..7];
file = outputDir.JoinFile($"{fileName}_{uuid}.png");
file = outputDir.JoinFile($"{fileName}_{uuid}.{fileExtension}");
}
await using var fileStream = file.Info.OpenWrite();
@ -270,8 +289,6 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
// if progress updates are received before the prompt starts
Task.Run(
async () =>
{
try
{
var delayTime = 250 - (int)timer.ElapsedMilliseconds;
if (delayTime > 0)
@ -280,8 +297,6 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
}
// ReSharper disable once AccessToDisposedClosure
AttachRunningNodeChangedHandler(promptTask);
}
catch (TaskCanceledException) { }
},
cancellationToken
)
@ -305,10 +320,17 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
// Get output images
var imageOutputs = await client.GetImagesForExecutedPromptAsync(promptTask.Id, cancellationToken);
if (imageOutputs.Values.All(images => images is null or { Count: 0 }))
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);
notificationService.Show(
"No output",
"Did not receive any output images",
NotificationType.Warning
);
return;
}
@ -320,7 +342,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
ImageGalleryCardViewModel.ImageSources.Clear();
}
await ProcessAllOutputImages(imageOutputs, args);
await ProcessOutputImages(images, args);
}
finally
{
@ -338,30 +360,12 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
}
}
private async Task ProcessAllOutputImages(
IReadOnlyDictionary<string, List<ComfyImage>?> images,
ImageGenerationEventArgs args
)
{
foreach (var (nodeName, imageList) in images)
{
if (imageList is null)
{
Logger.Warn("No images for node {NodeName}", nodeName);
continue;
}
await ProcessOutputImages(imageList, args, nodeName.Replace('_', ' '));
}
}
/// <summary>
/// Handles image output metadata for generation runs
/// </summary>
private async Task ProcessOutputImages(
IReadOnlyCollection<ComfyImage> images,
ImageGenerationEventArgs args,
string? imageLabel = null
ImageGenerationEventArgs args
)
{
var client = args.Client;
@ -405,15 +409,65 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
);
}
if (comfyImage.FileName.EndsWith(".png"))
{
var bytesWithMetadata = PngDataHelper.AddMetadata(imageArray, parameters, project);
// Write using generated name
var filePath = await WriteOutputImageAsync(new MemoryStream(bytesWithMetadata), args, i + 1, images.Count);
var filePath = await WriteOutputImageAsync(
new MemoryStream(bytesWithMetadata),
args,
i + 1,
images.Count
);
outputImages.Add(new ImageSource(filePath));
EventManager.Instance.OnImageFileAdded(filePath);
}
else if (comfyImage.FileName.EndsWith(".webp"))
{
var opts = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
var paramsJson = JsonSerializer.Serialize(parameters, opts);
var smProject = JsonSerializer.Serialize(project, opts);
var metadata = new Dictionary<ExifTag, string>
{
{ ExifTag.ImageDescription, paramsJson },
{ ExifTag.Software, smProject }
};
var bytesWithMetadata = ImageMetadata.AddMetadataToWebp(imageArray, metadata);
// Write using generated name
var filePath = await WriteOutputImageAsync(
new MemoryStream(bytesWithMetadata.ToArray()),
args,
i + 1,
images.Count,
fileExtension: Path.GetExtension(comfyImage.FileName).Replace(".", "")
);
outputImages.Add(new ImageSource(filePath) { Label = imageLabel });
outputImages.Add(new ImageSource(filePath));
EventManager.Instance.OnImageFileAdded(filePath);
}
else
{
// Write using generated name
var filePath = await WriteOutputImageAsync(
new MemoryStream(imageArray),
args,
i + 1,
images.Count,
fileExtension: Path.GetExtension(comfyImage.FileName).Replace(".", "")
);
outputImages.Add(new ImageSource(filePath));
EventManager.Instance.OnImageFileAdded(filePath);
}
}
// Download all images to make grid, if multiple
if (outputImages.Count > 1)
@ -430,11 +484,14 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
var gridBytesWithMetadata = PngDataHelper.AddMetadata(gridBytes, args.Parameters!, args.Project!);
// Save to disk
var gridPath = await WriteOutputImageAsync(new MemoryStream(gridBytesWithMetadata), args, isGrid: true);
var gridPath = await WriteOutputImageAsync(
new MemoryStream(gridBytesWithMetadata),
args,
isGrid: true
);
// Insert to start of images
var gridImage = new ImageSource(gridPath) { Label = imageLabel };
var gridImage = new ImageSource(gridPath);
// Preload
await gridImage.GetBitmapAsync();
ImageGalleryCardViewModel.ImageSources.Add(gridImage);
@ -465,7 +522,10 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
/// <param name="options">Optional overrides (side buttons)</param>
/// <param name="cancellationToken">Cancellation token</param>
[RelayCommand(IncludeCancelCommand = true, FlowExceptionsToTaskScheduler = true)]
private async Task GenerateImage(GenerateFlags options = default, CancellationToken cancellationToken = default)
private async Task GenerateImage(
GenerateFlags options = default,
CancellationToken cancellationToken = default
)
{
var overrides = GenerateOverrides.FromFlags(options);
@ -475,12 +535,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
}
catch (OperationCanceledException)
{
Logger.Debug("Image Generation Canceled");
}
catch (ValidationException e)
{
Logger.Debug("Image Generation Validation Error: {Message}", e.Message);
notificationService.Show("Validation Error", e.Message, NotificationType.Error);
Logger.Debug($"Image Generation Canceled");
}
}
@ -513,9 +568,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
/// </summary>
protected virtual void OnProgressUpdateReceived(object? sender, ComfyProgressUpdateEventArgs args)
{
Dispatcher
.UIThread
.Post(() =>
Dispatcher.UIThread.Post(() =>
{
OutputProgress.Value = args.Value;
OutputProgress.Maximum = args.Maximum;
@ -548,9 +601,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
return;
}
Dispatcher
.UIThread
.Post(() =>
Dispatcher.UIThread.Post(() =>
{
OutputProgress.IsIndeterminate = true;
OutputProgress.Value = 100;

54
StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs

@ -28,24 +28,16 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private static readonly Type[] SerializerIgnoredTypes =
{
typeof(ICommand),
typeof(IRelayCommand)
};
private static readonly Type[] SerializerIgnoredTypes = { typeof(ICommand), typeof(IRelayCommand) };
private static readonly string[] SerializerIgnoredNames = { nameof(HasErrors) };
private static readonly JsonSerializerOptions SerializerOptions =
new() { IgnoreReadOnlyProperties = true };
private static readonly JsonSerializerOptions SerializerOptions = new() { IgnoreReadOnlyProperties = true };
private static bool ShouldIgnoreProperty(PropertyInfo property)
{
// Skip if read-only and not IJsonLoadableState
if (
property.SetMethod is null
&& !typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType)
)
if (property.SetMethod is null && !typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType))
{
Logger.ConditionalTrace("Skipping {Property} - read-only", property.Name);
return true;
@ -107,11 +99,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
{
// Get all of our properties using reflection
var properties = GetType().GetProperties();
Logger.ConditionalTrace(
"Serializing {Type} with {Count} properties",
GetType(),
properties.Length
);
Logger.ConditionalTrace("Serializing {Type} with {Count} properties", GetType(), properties.Length);
foreach (var property in properties)
{
@ -119,9 +107,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
// If JsonPropertyName provided, use that as the key
if (
property
.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true)
.FirstOrDefault()
property.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).FirstOrDefault()
is JsonPropertyNameAttribute jsonPropertyName
)
{
@ -168,10 +154,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
if (property.GetValue(this) is not IJsonLoadableState propertyValue)
{
// If null, it must have a default constructor
if (
property.PropertyType.GetConstructor(Type.EmptyTypes)
is not { } constructorInfo
)
if (property.PropertyType.GetConstructor(Type.EmptyTypes) is not { } constructorInfo)
{
throw new InvalidOperationException(
$"Property {property.Name} is IJsonLoadableState but current object is null and has no default constructor"
@ -188,11 +171,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
}
else
{
Logger.ConditionalTrace(
"Loading {Property} ({Type})",
property.Name,
property.PropertyType
);
Logger.ConditionalTrace("Loading {Property} ({Type})", property.Name, property.PropertyType);
var propertyValue = value.Deserialize(property.PropertyType, SerializerOptions);
property.SetValue(this, propertyValue);
@ -216,11 +195,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
{
// Get all of our properties using reflection.
var properties = GetType().GetProperties();
Logger.ConditionalTrace(
"Serializing {Type} with {Count} properties",
GetType(),
properties.Length
);
Logger.ConditionalTrace("Serializing {Type} with {Count} properties", GetType(), properties.Length);
// Create a JSON object to store the state.
var state = new JsonObject();
@ -237,9 +212,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
// If JsonPropertyName provided, use that as the key.
if (
property
.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true)
.FirstOrDefault()
property.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).FirstOrDefault()
is JsonPropertyNameAttribute jsonPropertyName
)
{
@ -270,11 +243,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
}
else
{
Logger.ConditionalTrace(
"Serializing {Property} ({Type})",
property.Name,
property.PropertyType
);
Logger.ConditionalTrace("Serializing {Property} ({Type})", property.Name, property.PropertyType);
var value = property.GetValue(this);
if (value is not null)
{
@ -297,8 +266,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
protected static JsonObject SerializeModel<T>(T model)
{
var node = JsonSerializer.SerializeToNode(model);
return node?.AsObject()
?? throw new NullReferenceException("Failed to serialize state to JSON object.");
return node?.AsObject() ?? throw new NullReferenceException("Failed to serialize state to JSON object.");
}
/// <summary>

40
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs

@ -21,6 +21,7 @@ using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
@ -120,9 +121,7 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
var latestVersionInstalled =
latestVersion.Files != null
&& latestVersion
.Files
.Any(
&& latestVersion.Files.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModels.Contains(file.Hashes.BLAKE3)
@ -131,14 +130,10 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
// check if any of the ModelVersion.Files.Hashes.BLAKE3 hashes are in the installedModels list
var anyVersionInstalled =
latestVersionInstalled
|| CivitModel
.ModelVersions
.Any(
|| CivitModel.ModelVersions.Any(
version =>
version.Files != null
&& version
.Files
.Any(
&& version.Files.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModels.Contains(file.Hashes.BLAKE3)
@ -154,7 +149,6 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
ShowUpdateCard = anyVersionInstalled;
}
// Choose and load image based on nsfw setting
private void UpdateImage()
{
var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled;
@ -162,21 +156,17 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
var images = version?.Images;
// Try to find a valid image
var image = images?.FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None");
var image = images
?.Where(img => LocalModelFile.SupportedImageExtensions.Any(img.Url.Contains))
.FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None");
if (image != null)
{
// var imageStream = await downloadService.GetImageStreamFromUrl(image.Url);
// Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(imageStream); });
CardImage = new Uri(image.Url);
return;
}
// If no valid image found, use no image
CardImage = Assets.NoImage;
// var assetStream = AssetLoader.Open(new Uri("avares://StabilityMatrix.Avalonia/Assets/noimage.png"));
// Otherwise Default image
// Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(assetStream); });
}
[RelayCommand]
@ -270,8 +260,7 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
{
var prunedDescription =
model
.Description
?.Replace("<br/>", $"{Environment.NewLine}{Environment.NewLine}")
.Description?.Replace("<br/>", $"{Environment.NewLine}{Environment.NewLine}")
.Replace("<br />", $"{Environment.NewLine}{Environment.NewLine}")
.Replace("</p>", $"{Environment.NewLine}{Environment.NewLine}")
.Replace("</h1>", $"{Environment.NewLine}{Environment.NewLine}")
@ -318,9 +307,9 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
var imageExtension = Path.GetExtension(image.Url).TrimStart('.');
if (imageExtension is "jpg" or "jpeg" or "png")
{
var imageDownloadPath = modelFilePath
.Directory!
.JoinFile($"{modelFilePath.NameWithoutExtension}.preview.{imageExtension}");
var imageDownloadPath = modelFilePath.Directory!.JoinFile(
$"{modelFilePath.NameWithoutExtension}.preview.{imageExtension}"
);
var imageTask = downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
await notificationService.TryAsync(imageTask, "Could not download preview image");
@ -358,7 +347,8 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
}
// Get latest version file
var modelFile = selectedFile ?? modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
var modelFile =
selectedFile ?? modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
if (modelFile is null)
{
notificationService.Show(
@ -374,7 +364,9 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory);
var downloadDirectory = rootModelsDirectory.JoinDir(model.Type.ConvertTo<SharedFolderType>().GetStringValue());
var downloadDirectory = rootModelsDirectory.JoinDir(
model.Type.ConvertTo<SharedFolderType>().GetStringValue()
);
// Folders might be missing if user didn't install any packages yet
downloadDirectory.Create();

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

@ -70,7 +70,14 @@ public partial class CheckpointFile : ViewModelBase
public ObservableCollection<string> Badges { get; set; } = new();
public static readonly string[] SupportedCheckpointExtensions = { ".safetensors", ".pt", ".ckpt", ".pth", ".bin" };
public static readonly string[] SupportedCheckpointExtensions =
{
".safetensors",
".pt",
".ckpt",
".pth",
".bin"
};
private static readonly string[] SupportedImageExtensions = { ".png", ".jpg", ".jpeg", ".gif" };
private static readonly string[] SupportedMetadataExtensions = { ".json" };
@ -95,7 +102,9 @@ public partial class CheckpointFile : ViewModelBase
{
if (string.IsNullOrEmpty(FilePath))
{
throw new InvalidOperationException("Cannot get connected model info file path when FilePath is empty");
throw new InvalidOperationException(
"Cannot get connected model info file path when FilePath is empty"
);
}
var modelNameNoExt = Path.GetFileNameWithoutExtension((string?)FilePath);
var modelDir = Path.GetDirectoryName((string?)FilePath) ?? "";
@ -224,10 +233,21 @@ public partial class CheckpointFile : ViewModelBase
return App.Clipboard.SetTextAsync(words);
}
[RelayCommand]
private Task CopyModelUrl()
{
return ConnectedModel == null
? Task.CompletedTask
: App.Clipboard.SetTextAsync($"https://civitai.com/models/{ConnectedModel.ModelId}");
}
[RelayCommand]
private async Task FindConnectedMetadata(bool forceReimport = false)
{
if (App.Services.GetService(typeof(IMetadataImportService)) is not IMetadataImportService importService)
if (
App.Services.GetService(typeof(IMetadataImportService))
is not IMetadataImportService importService
)
return;
IsLoading = true;
@ -298,7 +318,9 @@ public partial class CheckpointFile : ViewModelBase
}
checkpointFile.PreviewImagePath = SupportedImageExtensions
.Select(ext => Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(file)}.preview{ext}"))
.Select(
ext => Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(file)}.preview{ext}")
)
.Where(File.Exists)
.FirstOrDefault();
@ -324,7 +346,11 @@ public partial class CheckpointFile : ViewModelBase
)
continue;
var checkpointFile = new CheckpointFile { Title = Path.GetFileNameWithoutExtension(file), FilePath = file };
var checkpointFile = new CheckpointFile
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = file
};
var jsonPath = Path.Combine(
Path.GetDirectoryName(file) ?? "",
@ -432,5 +458,6 @@ public partial class CheckpointFile : ViewModelBase
}
}
public static IEqualityComparer<CheckpointFile> FilePathComparer { get; } = new FilePathEqualityComparer();
public static IEqualityComparer<CheckpointFile> FilePathComparer { get; } =
new FilePathEqualityComparer();
}

2
StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs

@ -7,9 +7,9 @@ using System.Threading.Tasks.Dataflow;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using CommunityToolkit.Mvvm.ComponentModel;
using NLog;
using Nito.AsyncEx;
using Nito.AsyncEx.Synchronous;
using NLog;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Processes;

17
StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs

@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views;
@ -65,8 +66,9 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa
this.packageFactory = packageFactory;
// Get comfy type installed packages
var comfyPackages = this.settingsManager.Settings.InstalledPackages
.Where(p => p.PackageName == "ComfyUI")
var comfyPackages = this.settingsManager.Settings.InstalledPackages.Where(
p => p.PackageName == "ComfyUI"
)
.ToImmutableArray();
InstalledPackages = comfyPackages;
@ -103,13 +105,14 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa
{
Dispatcher.UIThread.Post(() =>
{
navigationService.NavigateTo<PackageManagerViewModel>(
param: new PackageManagerPage.PackageManagerNavigationOptions
navigationService.NavigateTo<NewPackageManagerViewModel>(
param: new PackageManagerNavigationOptions
{
OpenInstallerDialog = true,
InstallerSelectedPackage = packageFactory
.GetAllAvailablePackages()
.First(p => p is ComfyUI)
.OfType<ComfyUI>()
.First()
}
);
});
@ -138,9 +141,7 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa
var dialog = new BetterContentDialog
{
Content = new InferenceConnectionHelpDialog { DataContext = this },
PrimaryButtonCommand = IsInstallMode
? NavigateToInstallCommand
: LaunchSelectedPackageCommand,
PrimaryButtonCommand = IsInstallMode ? NavigateToInstallCommand : LaunchSelectedPackageCommand,
PrimaryButtonText = IsInstallMode ? Resources.Action_Install : Resources.Action_Launch,
CloseButtonText = Resources.Action_Close,
DefaultButton = ContentDialogButton.Primary

64
StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs

@ -20,6 +20,7 @@ using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
@ -34,9 +35,7 @@ using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[ManagedService]
[Transient]
public partial class InstallerViewModel : ContentDialogViewModelBase
public partial class InstallerViewModel : PageViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
@ -46,6 +45,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly ILogger<InstallerViewModel> logger;
public override string Title => "Add Package";
[ObservableProperty]
private BasePackage selectedPackage;
@ -90,11 +91,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
// Version types (release or commit)
[ObservableProperty]
[NotifyPropertyChangedFor(
nameof(ReleaseLabelText),
nameof(IsReleaseMode),
nameof(SelectedVersion)
)]
[NotifyPropertyChangedFor(nameof(ReleaseLabelText), nameof(IsReleaseMode), nameof(SelectedVersion))]
private PackageVersionType selectedVersionType = PackageVersionType.Commit;
[ObservableProperty]
@ -106,22 +103,16 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
[NotifyPropertyChangedFor(nameof(CanInstall))]
private bool isLoading;
public string ReleaseLabelText =>
IsReleaseMode ? Resources.Label_Version : Resources.Label_Branch;
public string ReleaseLabelText => IsReleaseMode ? Resources.Label_Version : Resources.Label_Branch;
public bool IsReleaseMode
{
get => SelectedVersionType == PackageVersionType.GithubRelease;
set =>
SelectedVersionType = value
? PackageVersionType.GithubRelease
: PackageVersionType.Commit;
set => SelectedVersionType = value ? PackageVersionType.GithubRelease : PackageVersionType.Commit;
}
public bool IsReleaseModeAvailable =>
AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease);
public bool IsReleaseModeAvailable => AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease);
public bool ShowTorchVersionOptions => SelectedTorchVersion != TorchVersion.None;
public bool CanInstall =>
!string.IsNullOrWhiteSpace(InstallName) && !ShowDuplicateWarning && !IsLoading;
public bool CanInstall => !string.IsNullOrWhiteSpace(InstallName) && !ShowDuplicateWarning && !IsLoading;
public IEnumerable<IPackageStep> Steps { get; set; }
@ -148,6 +139,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
AvailablePackages = new ObservableCollection<BasePackage>(
filtered.Any() ? filtered : packageFactory.GetAllAvailablePackages()
);
SelectedPackage = AvailablePackages.FirstOrDefault();
ShowIncompatiblePackages = !filtered.Any();
}
@ -207,7 +199,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
);
if (result.IsSuccessful)
{
OnPrimaryButtonClick();
// OnPrimaryButtonClick();
}
else
{
@ -254,10 +246,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
if (IsReleaseMode)
{
downloadOptions.VersionTag =
SelectedVersion?.TagName
?? throw new NullReferenceException("Selected version is null");
downloadOptions.IsLatest =
AvailableVersions?.First().TagName == downloadOptions.VersionTag;
SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null");
downloadOptions.IsLatest = AvailableVersions?.First().TagName == downloadOptions.VersionTag;
downloadOptions.IsPrerelease = SelectedVersion.IsPrerelease;
installedVersion.InstalledReleaseVersion = downloadOptions.VersionTag;
@ -268,21 +258,15 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
downloadOptions.CommitHash =
SelectedCommit?.Sha ?? throw new NullReferenceException("Selected commit is null");
downloadOptions.BranchName =
SelectedVersion?.TagName
?? throw new NullReferenceException("Selected version is null");
SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null");
downloadOptions.IsLatest = AvailableCommits?.First().Sha == SelectedCommit.Sha;
installedVersion.InstalledBranch =
SelectedVersion?.TagName
?? throw new NullReferenceException("Selected version is null");
SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null");
installedVersion.InstalledCommitSha = downloadOptions.CommitHash;
}
var downloadStep = new DownloadPackageVersionStep(
SelectedPackage,
installLocation,
downloadOptions
);
var downloadStep = new DownloadPackageVersionStep(SelectedPackage, installLocation, downloadOptions);
var installStep = new InstallPackageStep(
SelectedPackage,
SelectedTorchVersion,
@ -326,7 +310,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
public void Cancel()
{
OnCloseButtonClick();
// OnCloseButtonClick();
}
partial void OnShowIncompatiblePackagesChanged(bool value)
@ -351,9 +335,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
else
{
// First try to find the package-defined main branch
var version = AvailableVersions.FirstOrDefault(
x => x.TagName == SelectedPackage.MainBranch
);
var version = AvailableVersions.FirstOrDefault(x => x.TagName == SelectedPackage.MainBranch);
// If not found, try main
version ??= AvailableVersions.FirstOrDefault(x => x.TagName == "main");
@ -406,8 +388,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
if (SelectedPackage is null || Design.IsDesignMode)
return;
Dispatcher.UIThread
.InvokeAsync(async () =>
Dispatcher
.UIThread.InvokeAsync(async () =>
{
logger.LogDebug($"Release mode: {IsReleaseMode}");
var versionOptions = await SelectedPackage.GetAllVersionOptions();
@ -425,9 +407,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
if (!IsReleaseMode)
{
var commits = (
await SelectedPackage.GetAllCommits(SelectedVersion.TagName)
)?.ToList();
var commits = (await SelectedPackage.GetAllCommits(SelectedVersion.TagName))?.ToList();
if (commits is null || commits.Count == 0)
return;
@ -506,4 +486,6 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
.SafeFireAndForget();
}
}
public override IconSource IconSource { get; }
}

38
StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs

@ -8,20 +8,42 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class ModelVersionViewModel : ObservableObject
{
[ObservableProperty] private CivitModelVersion modelVersion;
[ObservableProperty] private ObservableCollection<CivitFileViewModel> civitFileViewModels;
[ObservableProperty] private bool isInstalled;
private readonly HashSet<string> installedModelHashes;
[ObservableProperty]
private CivitModelVersion modelVersion;
[ObservableProperty]
private ObservableCollection<CivitFileViewModel> civitFileViewModels;
[ObservableProperty]
private bool isInstalled;
public ModelVersionViewModel(HashSet<string> installedModelHashes, CivitModelVersion modelVersion)
{
this.installedModelHashes = installedModelHashes;
ModelVersion = modelVersion;
IsInstalled = ModelVersion.Files?.Any(file =>
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} &&
installedModelHashes.Contains(file.Hashes.BLAKE3)) ?? false;
IsInstalled =
ModelVersion.Files?.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModelHashes.Contains(file.Hashes.BLAKE3)
) ?? false;
CivitFileViewModels = new ObservableCollection<CivitFileViewModel>(
ModelVersion.Files?.Select(file => new CivitFileViewModel(installedModelHashes, file)) ??
new List<CivitFileViewModel>());
ModelVersion.Files?.Select(file => new CivitFileViewModel(installedModelHashes, file))
?? new List<CivitFileViewModel>()
);
}
public void RefreshInstallStatus()
{
IsInstalled =
ModelVersion.Files?.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModelHashes.Contains(file.Hashes.BLAKE3)
) ?? false;
}
}

15
StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs

@ -140,10 +140,7 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase
? UpgradePackageVersion(
item.Package.Name,
item.SelectedVersion,
PythonPackagesItemViewModel.GetKnownIndexUrl(
item.Package.Name,
item.SelectedVersion
),
PythonPackagesItemViewModel.GetKnownIndexUrl(item.Package.Name, item.SelectedVersion),
isDowngrade: item.CanDowngrade
)
: Task.CompletedTask;
@ -167,9 +164,7 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase
Resources.Label_ConfirmQuestion
);
dialog.PrimaryButtonText = isDowngrade
? Resources.Action_Downgrade
: Resources.Action_Upgrade;
dialog.PrimaryButtonText = isDowngrade ? Resources.Action_Downgrade : Resources.Action_Upgrade;
dialog.IsPrimaryButtonEnabled = true;
dialog.DefaultButton = ContentDialogButton.Primary;
dialog.CloseButtonText = Resources.Action_Cancel;
@ -225,7 +220,7 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase
var dialog = DialogHelper.CreateTextEntryDialog("Install Package", "", fields);
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary || fields[0].Text is not { } packageName)
if (result != ContentDialogResult.Primary || fields[0].Text is not { } packageArgs)
{
return;
}
@ -236,14 +231,14 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase
{
VenvDirectory = VenvPath,
WorkingDirectory = VenvPath.Parent,
Args = new[] { "install", packageName }
Args = new ProcessArgs(packageArgs).Prepend("install")
}
};
var runner = new PackageModificationRunner
{
ShowDialogOnStart = true,
ModificationCompleteMessage = $"Installed Python Package '{packageName}'"
ModificationCompleteMessage = $"Installed Python Package '{packageArgs}'"
};
EventManager.Instance.OnPackageInstallProgressAdded(runner);
await runner.ExecuteSteps(steps);

104
StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs

@ -1,15 +1,22 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls.Notifications;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
@ -20,6 +27,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IDownloadService downloadService;
private readonly IModelIndexService modelIndexService;
private readonly INotificationService notificationService;
public required ContentDialog Dialog { get; set; }
public required IReadOnlyList<ModelVersionViewModel> Versions { get; set; }
@ -56,10 +65,17 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
public int DisplayedPageNumber => SelectedImageIndex + 1;
public SelectModelVersionViewModel(ISettingsManager settingsManager, IDownloadService downloadService)
public SelectModelVersionViewModel(
ISettingsManager settingsManager,
IDownloadService downloadService,
IModelIndexService modelIndexService,
INotificationService notificationService
)
{
this.settingsManager = settingsManager;
this.downloadService = downloadService;
this.modelIndexService = modelIndexService;
this.notificationService = notificationService;
}
public override void OnLoaded()
@ -99,6 +115,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
partial void OnSelectedFileChanged(CivitFileViewModel? value)
{
if (value is { IsInstalled: true }) { }
var canImport = true;
if (settingsManager.IsLibraryDirSet)
{
@ -133,6 +151,90 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
Dialog.Hide(ContentDialogResult.Primary);
}
public async Task Delete()
{
if (SelectedFile == null)
return;
var fileToDelete = SelectedFile;
var originalSelectedVersionVm = SelectedVersionViewModel;
var hash = fileToDelete.CivitFile.Hashes.BLAKE3;
if (string.IsNullOrWhiteSpace(hash))
{
notificationService.Show(
"Error deleting file",
"Could not delete model, hash is missing.",
NotificationType.Error
);
return;
}
var matchingModels = (await modelIndexService.FindByHashAsync(hash)).ToList();
if (matchingModels.Count == 0)
{
await modelIndexService.RefreshIndex();
matchingModels = (await modelIndexService.FindByHashAsync(hash)).ToList();
if (matchingModels.Count == 0)
{
notificationService.Show(
"Error deleting file",
"Could not delete model, model not found in index.",
NotificationType.Error
);
return;
}
}
var dialog = new BetterContentDialog
{
Title = Resources.Label_AreYouSure,
MaxDialogWidth = 750,
MaxDialogHeight = 850,
PrimaryButtonText = Resources.Action_Yes,
IsPrimaryButtonEnabled = true,
IsSecondaryButtonEnabled = false,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Close,
Content =
$"The following files:\n{string.Join('\n', matchingModels.Select(x => $"- {x.FileName}"))}\n"
+ "and all associated metadata files will be deleted. Are you sure?",
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
foreach (var localModel in matchingModels)
{
var checkpointPath = new FilePath(localModel.GetFullPath(settingsManager.ModelsDirectory));
if (File.Exists(checkpointPath))
{
File.Delete(checkpointPath);
}
var previewPath = localModel.GetPreviewImageFullPath(settingsManager.ModelsDirectory);
if (File.Exists(previewPath))
{
File.Delete(previewPath);
}
var cmInfoPath = checkpointPath.ToString().Replace(checkpointPath.Extension, ".cm-info.json");
if (File.Exists(cmInfoPath))
{
File.Delete(cmInfoPath);
}
await modelIndexService.RemoveModelAsync(localModel);
}
settingsManager.Transaction(settings => settings.InstalledModelHashes?.Remove(hash));
fileToDelete.IsInstalled = false;
originalSelectedVersionVm?.RefreshInstallStatus();
}
}
public void PreviousImage()
{
if (SelectedImageIndex > 0)

23
StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs

@ -126,10 +126,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
ShowProgressBar = true;
IsProgressIndeterminate = true;
UpdateText = string.Format(
Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix
);
UpdateText = string.Format(Resources.TextTemplate_UpdatingPackage, Resources.Label_StabilityMatrix);
try
{
@ -173,10 +170,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
UpdateText = "Getting a few things ready...";
await using (new MinimumDelay(500, 1000))
{
Process.Start(
UpdateHelper.ExecutablePath,
$"--wait-for-exit-pid {Environment.ProcessId}"
);
Process.Start(UpdateHelper.ExecutablePath, $"--wait-for-exit-pid {Environment.ProcessId}");
}
UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds...";
@ -262,15 +256,10 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
// Join all blocks until and excluding the current version
// If we're on a pre-release, include the current release
var currentVersionBlock = results.FindIndex(
x => x.Version == currentVersion.WithoutMetadata()
);
var currentVersionBlock = results.FindIndex(x => x.Version == currentVersion.WithoutMetadata());
// For mismatching build metadata, add one
if (
currentVersionBlock != -1
&& results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata
)
if (currentVersionBlock != -1 && results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata)
{
currentVersionBlock++;
}
@ -278,9 +267,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
// Support for previous pre-release without changelogs
if (currentVersionBlock == -1)
{
currentVersionBlock = results.FindIndex(
x => x.Version == currentVersion.WithoutPrereleaseOrMetadata()
);
currentVersionBlock = results.FindIndex(x => x.Version == currentVersion.WithoutPrereleaseOrMetadata());
// Add 1 if found to include the current release
if (currentVersionBlock != -1)

13
StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs

@ -0,0 +1,13 @@
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models.Packages.Extensions;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ExtensionViewModel() : ViewModelBase
{
[ObservableProperty]
private bool isSelected;
public ExtensionBase Extension { get; init; }
}

17
StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs

@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
@ -42,20 +43,32 @@ public partial class FirstLaunchSetupViewModel : ViewModelBase
private async Task<bool> SetGpuInfo()
{
GpuInfo[] gpuInfo;
await using (new MinimumDelay(800, 1200))
{
// Query GPU info
gpuInfo = await Task.Run(() => HardwareHelper.IterGpuInfo().ToArray());
}
// First Nvidia GPU
var activeGpu = gpuInfo.FirstOrDefault(gpu => gpu.Name?.ToLowerInvariant().Contains("nvidia") ?? false);
var activeGpu = gpuInfo.FirstOrDefault(
gpu => gpu.Name?.Contains("nvidia", StringComparison.InvariantCultureIgnoreCase) ?? false
);
var isNvidia = activeGpu is not null;
// Otherwise first GPU
activeGpu ??= gpuInfo.FirstOrDefault();
GpuInfoText = activeGpu is null
? "No GPU detected"
: $"{activeGpu.Name} ({Size.FormatBytes(activeGpu.MemoryBytes)})";
// Always succeed for macos arm
if (Compat.IsMacOS && Compat.IsArm)
{
return true;
}
return isNvidia;
}

245
StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs

@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using NLog;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Inference.Video;
using StabilityMatrix.Avalonia.Views.Inference;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Inference;
[View(typeof(InferenceImageToVideoView), persistent: true)]
[ManagedService]
[Transient]
public partial class InferenceImageToVideoViewModel
: InferenceGenerationViewModelBase,
IParametersLoadableState
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly INotificationService notificationService;
private readonly IModelIndexService modelIndexService;
[JsonIgnore]
public StackCardViewModel StackCardViewModel { get; }
[JsonPropertyName("Model")]
public ImgToVidModelCardViewModel ModelCardViewModel { get; }
[JsonPropertyName("Sampler")]
public SamplerCardViewModel SamplerCardViewModel { get; }
[JsonPropertyName("BatchSize")]
public BatchSizeCardViewModel BatchSizeCardViewModel { get; }
[JsonPropertyName("Seed")]
public SeedCardViewModel SeedCardViewModel { get; }
[JsonPropertyName("ImageLoader")]
public SelectImageCardViewModel SelectImageCardViewModel { get; }
[JsonPropertyName("Conditioning")]
public SvdImgToVidConditioningViewModel SvdImgToVidConditioningViewModel { get; }
[JsonPropertyName("VideoOutput")]
public VideoOutputSettingsCardViewModel VideoOutputSettingsCardViewModel { get; }
public InferenceImageToVideoViewModel(
INotificationService notificationService,
IInferenceClientManager inferenceClientManager,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> vmFactory,
IModelIndexService modelIndexService
)
: base(vmFactory, inferenceClientManager, notificationService, settingsManager)
{
this.notificationService = notificationService;
this.modelIndexService = modelIndexService;
// Get sub view models from service manager
SeedCardViewModel = vmFactory.Get<SeedCardViewModel>();
SeedCardViewModel.GenerateNewSeed();
ModelCardViewModel = vmFactory.Get<ImgToVidModelCardViewModel>();
SamplerCardViewModel = vmFactory.Get<SamplerCardViewModel>(samplerCard =>
{
samplerCard.IsDimensionsEnabled = true;
samplerCard.IsCfgScaleEnabled = true;
samplerCard.IsSamplerSelectionEnabled = true;
samplerCard.IsSchedulerSelectionEnabled = true;
samplerCard.CfgScale = 2.5d;
samplerCard.SelectedSampler = ComfySampler.Euler;
samplerCard.SelectedScheduler = ComfyScheduler.Karras;
samplerCard.IsDenoiseStrengthEnabled = true;
samplerCard.DenoiseStrength = 1.0f;
});
BatchSizeCardViewModel = vmFactory.Get<BatchSizeCardViewModel>();
SelectImageCardViewModel = vmFactory.Get<SelectImageCardViewModel>();
SvdImgToVidConditioningViewModel = vmFactory.Get<SvdImgToVidConditioningViewModel>();
VideoOutputSettingsCardViewModel = vmFactory.Get<VideoOutputSettingsCardViewModel>();
StackCardViewModel = vmFactory.Get<StackCardViewModel>();
StackCardViewModel.AddCards(
ModelCardViewModel,
SvdImgToVidConditioningViewModel,
SamplerCardViewModel,
SeedCardViewModel,
VideoOutputSettingsCardViewModel,
BatchSizeCardViewModel
);
}
/// <inheritdoc />
protected override void BuildPrompt(BuildPromptEventArgs args)
{
base.BuildPrompt(args);
var builder = args.Builder;
builder.Connections.Seed = args.SeedOverride switch
{
{ } seed => Convert.ToUInt64(seed),
_ => Convert.ToUInt64(SeedCardViewModel.Seed)
};
// Load models
ModelCardViewModel.ApplyStep(args);
// Setup latent from image
var imageLoad = builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.LoadImage
{
Name = builder.Nodes.GetUniqueName("ControlNet_LoadImage"),
Image =
SelectImageCardViewModel.ImageSource?.GetHashGuidFileNameCached("Inference")
?? throw new ValidationException()
}
);
builder.Connections.Primary = imageLoad.Output1;
builder.Connections.PrimarySize = SelectImageCardViewModel.CurrentBitmapSize;
// Setup img2vid stuff
// Set width & height from SamplerCard
SvdImgToVidConditioningViewModel.Width = SamplerCardViewModel.Width;
SvdImgToVidConditioningViewModel.Height = SamplerCardViewModel.Height;
SvdImgToVidConditioningViewModel.ApplyStep(args);
// Setup Sampler and Refiner if enabled
SamplerCardViewModel.ApplyStep(args);
// Animated webp output
VideoOutputSettingsCardViewModel.ApplyStep(args);
}
/// <inheritdoc />
protected override IEnumerable<ImageSource> GetInputImages()
{
if (SelectImageCardViewModel.ImageSource is { } image)
{
yield return image;
}
}
/// <inheritdoc />
protected override async Task GenerateImageImpl(
GenerateOverrides overrides,
CancellationToken cancellationToken
)
{
if (!await CheckClientConnectedWithPrompt() || !ClientManager.IsConnected)
{
return;
}
// If enabled, randomize the seed
var seedCard = StackCardViewModel.GetCard<SeedCardViewModel>();
if (overrides is not { UseCurrentSeed: true } && seedCard.IsRandomizeEnabled)
{
seedCard.GenerateNewSeed();
}
var batches = BatchSizeCardViewModel.BatchCount;
var batchArgs = new List<ImageGenerationEventArgs>();
for (var i = 0; i < batches; i++)
{
var seed = seedCard.Seed + i;
var buildPromptArgs = new BuildPromptEventArgs { Overrides = overrides, SeedOverride = seed };
BuildPrompt(buildPromptArgs);
var generationArgs = new ImageGenerationEventArgs
{
Client = ClientManager.Client,
Nodes = buildPromptArgs.Builder.ToNodeDictionary(),
OutputNodeNames = buildPromptArgs.Builder.Connections.OutputNodeNames.ToArray(),
Parameters = SaveStateToParameters(new GenerationParameters()),
Project = InferenceProjectDocument.FromLoadable(this),
// Only clear output images on the first batch
ClearOutputImages = i == 0
};
batchArgs.Add(generationArgs);
}
// Run batches
foreach (var args in batchArgs)
{
await RunGeneration(args, cancellationToken);
}
}
/// <inheritdoc />
public void LoadStateFromParameters(GenerationParameters parameters)
{
SamplerCardViewModel.LoadStateFromParameters(parameters);
ModelCardViewModel.LoadStateFromParameters(parameters);
SvdImgToVidConditioningViewModel.LoadStateFromParameters(parameters);
VideoOutputSettingsCardViewModel.LoadStateFromParameters(parameters);
SeedCardViewModel.Seed = Convert.ToInt64(parameters.Seed);
}
/// <inheritdoc />
public GenerationParameters SaveStateToParameters(GenerationParameters parameters)
{
parameters = SamplerCardViewModel.SaveStateToParameters(parameters);
parameters = ModelCardViewModel.SaveStateToParameters(parameters);
parameters = SvdImgToVidConditioningViewModel.SaveStateToParameters(parameters);
parameters = VideoOutputSettingsCardViewModel.SaveStateToParameters(parameters);
parameters.Seed = (ulong)SeedCardViewModel.Seed;
return parameters;
}
// Migration for v2 deserialization
public override void LoadStateFromJsonObject(JsonObject state, int version)
{
if (version > 2)
{
LoadStateFromJsonObject(state);
}
}
}

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

@ -107,9 +107,7 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase
// If upscale is enabled, add another upscale group
if (IsUpscaleEnabled)
{
var upscaleSize = builder.Connections.PrimarySize.WithScale(
UpscalerCardViewModel.Scale
);
var upscaleSize = builder.Connections.PrimarySize.WithScale(UpscalerCardViewModel.Scale);
// Build group
builder.Connections.Primary = builder
@ -144,10 +142,7 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase
}
/// <inheritdoc />
protected override async Task GenerateImageImpl(
GenerateOverrides overrides,
CancellationToken cancellationToken
)
protected override async Task GenerateImageImpl(GenerateOverrides overrides, CancellationToken cancellationToken)
{
if (!ClientManager.IsConnected)
{
@ -174,10 +169,7 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase
Client = ClientManager.Client,
Nodes = buildPromptArgs.Builder.ToNodeDictionary(),
OutputNodeNames = buildPromptArgs.Builder.Connections.OutputNodeNames.ToArray(),
Parameters = new GenerationParameters
{
ModelName = UpscalerCardViewModel.SelectedUpscaler?.Name,
},
Parameters = new GenerationParameters { ModelName = UpscalerCardViewModel.SelectedUpscaler?.Name, },
Project = InferenceProjectDocument.FromLoadable(this)
};

77
StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs

@ -38,45 +38,54 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager)
[ObservableProperty]
private bool isVaeSelectionEnabled;
[ObservableProperty]
private bool disableSettings;
[ObservableProperty]
private bool isClipSkipEnabled;
[NotifyDataErrorInfo]
[ObservableProperty]
[Range(1, 24)]
private int clipSkip = 1;
public IInferenceClientManager ClientManager { get; } = clientManager;
/// <inheritdoc />
public void ApplyStep(ModuleApplyStepEventArgs e)
public virtual void ApplyStep(ModuleApplyStepEventArgs e)
{
// Load base checkpoint
var baseLoader = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CheckpointLoaderSimple
{
Name = "CheckpointLoader",
CkptName =
SelectedModel?.RelativePath
?? throw new ValidationException("Model not selected")
Name = "CheckpointLoader_Base",
CkptName = SelectedModel?.RelativePath ?? throw new ValidationException("Model not selected")
}
);
e.Builder.Connections.BaseModel = baseLoader.Output1;
e.Builder.Connections.BaseClip = baseLoader.Output2;
e.Builder.Connections.BaseVAE = baseLoader.Output3;
e.Builder.Connections.Base.Model = baseLoader.Output1;
e.Builder.Connections.Base.Clip = baseLoader.Output2;
e.Builder.Connections.Base.VAE = baseLoader.Output3;
// Load refiner
// Load refiner if enabled
if (IsRefinerSelectionEnabled && SelectedRefiner is { IsNone: false })
{
var refinerLoader = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CheckpointLoaderSimple
{
Name = "Refiner_CheckpointLoader",
Name = "CheckpointLoader_Refiner",
CkptName =
SelectedRefiner?.RelativePath
?? throw new ValidationException("Refiner Model enabled but not selected")
}
);
e.Builder.Connections.RefinerModel = refinerLoader.Output1;
e.Builder.Connections.RefinerClip = refinerLoader.Output2;
e.Builder.Connections.RefinerVAE = refinerLoader.Output3;
e.Builder.Connections.Refiner.Model = refinerLoader.Output1;
e.Builder.Connections.Refiner.Clip = refinerLoader.Output2;
e.Builder.Connections.Refiner.VAE = refinerLoader.Output3;
}
// Load custom VAE
// Load VAE override if enabled
if (IsVaeSelectionEnabled && SelectedVae is { IsNone: false, IsDefault: false })
{
var vaeLoader = e.Nodes.AddTypedNode(
@ -91,6 +100,28 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager)
e.Builder.Connections.PrimaryVAE = vaeLoader.Output;
}
// Clip skip all models if enabled
if (IsClipSkipEnabled)
{
foreach (var (modelName, model) in e.Builder.Connections.Models)
{
if (model.Clip is not { } modelClip)
continue;
var clipSetLastLayer = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPSetLastLayer
{
Name = $"CLIP_Skip_{modelName}",
Clip = modelClip,
// Need to convert to negative indexing from (1 to 24) to (-1 to -24)
StopAtClipLayer = -ClipSkip
}
);
model.Clip = clipSetLastLayer.Output;
}
}
}
/// <inheritdoc />
@ -102,8 +133,10 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager)
SelectedModelName = SelectedModel?.RelativePath,
SelectedVaeName = SelectedVae?.RelativePath,
SelectedRefinerName = SelectedRefiner?.RelativePath,
ClipSkip = ClipSkip,
IsVaeSelectionEnabled = IsVaeSelectionEnabled,
IsRefinerSelectionEnabled = IsRefinerSelectionEnabled
IsRefinerSelectionEnabled = IsRefinerSelectionEnabled,
IsClipSkipEnabled = IsClipSkipEnabled
}
);
}
@ -125,8 +158,11 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager)
? HybridModelFile.None
: ClientManager.Models.FirstOrDefault(x => x.RelativePath == model.SelectedRefinerName);
ClipSkip = model.ClipSkip;
IsVaeSelectionEnabled = model.IsVaeSelectionEnabled;
IsRefinerSelectionEnabled = model.IsRefinerSelectionEnabled;
IsClipSkipEnabled = model.IsClipSkipEnabled;
}
/// <inheritdoc />
@ -145,19 +181,14 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager)
model = currentModels.FirstOrDefault(
m =>
m.Local?.ConnectedModelInfo?.Hashes.SHA256 is { } sha256
&& sha256.StartsWith(
parameters.ModelHash,
StringComparison.InvariantCultureIgnoreCase
)
&& sha256.StartsWith(parameters.ModelHash, StringComparison.InvariantCultureIgnoreCase)
);
}
else
{
// Name matches
model = currentModels.FirstOrDefault(m => m.RelativePath.EndsWith(paramsModelName));
model ??= currentModels.FirstOrDefault(
m => m.ShortDisplayName.StartsWith(paramsModelName)
);
model ??= currentModels.FirstOrDefault(m => m.ShortDisplayName.StartsWith(paramsModelName));
}
if (model is not null)
@ -181,8 +212,10 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager)
public string? SelectedModelName { get; init; }
public string? SelectedRefinerName { get; init; }
public string? SelectedVaeName { get; init; }
public int ClipSkip { get; init; } = 1;
public bool IsVaeSelectionEnabled { get; init; }
public bool IsRefinerSelectionEnabled { get; init; }
public bool IsClipSkipEnabled { get; init; }
}
}

6
StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Services;
@ -81,8 +79,8 @@ public class ControlNetModule : ModuleBase
Name = e.Nodes.GetUniqueName("Refiner_ControlNetApply"),
Image = imageLoad.Output1,
ControlNet = controlNetLoader.Output,
Positive = e.Temp.RefinerConditioning.Value.Positive,
Negative = e.Temp.RefinerConditioning.Value.Negative,
Positive = e.Temp.RefinerConditioning.Positive,
Negative = e.Temp.RefinerConditioning.Negative,
Strength = card.Strength,
StartPercent = card.StartPercent,
EndPercent = card.EndPercent,

31
StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs

@ -1,7 +1,9 @@
using StabilityMatrix.Avalonia.Models.Inference;
using System.Linq;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
namespace StabilityMatrix.Avalonia.ViewModels.Inference.Modules;
@ -25,34 +27,17 @@ public class FreeUModule : ModuleBase
{
var card = GetCard<FreeUCardViewModel>();
// Currently applies to both base and refiner model
// Currently applies to all models
// TODO: Add option to apply to either base or refiner
if (e.Builder.Connections.BaseModel is not null)
foreach (var modelConnections in e.Builder.Connections.Models.Values.Where(m => m.Model is not null))
{
e.Builder.Connections.BaseModel = e.Nodes
modelConnections.Model = e.Nodes
.AddTypedNode(
new ComfyNodeBuilder.FreeU
{
Name = e.Nodes.GetUniqueName("FreeU"),
Model = e.Builder.Connections.BaseModel,
B1 = card.B1,
B2 = card.B2,
S1 = card.S1,
S2 = card.S2
}
)
.Output;
}
if (e.Builder.Connections.RefinerModel is not null)
{
e.Builder.Connections.RefinerModel = e.Nodes
.AddTypedNode(
new ComfyNodeBuilder.FreeU
{
Name = e.Nodes.GetUniqueName("Refiner_FreeU"),
Model = e.Builder.Connections.RefinerModel,
Name = e.Nodes.GetUniqueName($"FreeU_{modelConnections.Name}"),
Model = modelConnections.Model!,
B1 = card.B1,
B2 = card.B2,
S1 = card.S1,

9
StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs

@ -1,10 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Services;
@ -73,7 +70,7 @@ public partial class HiresFixModule : ModuleBase
{
builder.Connections.Primary = builder.Group_Upscale(
builder.Nodes.GetUniqueName("HiresFix"),
builder.Connections.Primary ?? throw new ArgumentException("No Primary"),
builder.Connections.Primary.Unwrap(),
builder.Connections.GetDefaultVAE(),
selectedUpscaler,
hiresSize.Width,
@ -99,8 +96,8 @@ public partial class HiresFixModule : ModuleBase
samplerCard.SelectedScheduler?.Name
?? e.Builder.Connections.PrimaryScheduler?.Name
?? throw new ArgumentException("No PrimaryScheduler"),
Positive = builder.Connections.GetRefinerOrBaseConditioning(),
Negative = builder.Connections.GetRefinerOrBaseNegativeConditioning(),
Positive = builder.Connections.GetRefinerOrBaseConditioning().Positive,
Negative = builder.Connections.GetRefinerOrBaseConditioning().Negative,
LatentImage = builder.GetPrimaryAsLatent(),
Denoise = samplerCard.DenoiseStrength
}

121
StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs

@ -18,6 +18,7 @@ using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
using StabilityMatrix.Core.Services;
@ -26,10 +27,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Inference;
[View(typeof(PromptCard))]
[ManagedService]
[Transient]
public partial class PromptCardViewModel
: LoadableViewModelBase,
IParametersLoadableState,
IComfyStep
public partial class PromptCardViewModel : LoadableViewModelBase, IParametersLoadableState, IComfyStep
{
private readonly IModelIndexService modelIndexService;
@ -74,13 +72,11 @@ public partial class PromptCardViewModel
/// Applies the prompt step.
/// Requires:
/// <list type="number">
/// <item><see cref="ComfyNodeBuilder.NodeBuilderConnections.BaseModel"/></item>
/// <item><see cref="ComfyNodeBuilder.NodeBuilderConnections.BaseClip"/></item>
/// <item><see cref="ComfyNodeBuilder.NodeBuilderConnections.Base"/> - Model, Clip</item>
/// </list>
/// Provides:
/// <list type="number">
/// <item><see cref="ComfyNodeBuilder.NodeBuilderConnections.BaseConditioning"/></item>
/// <item><see cref="ComfyNodeBuilder.NodeBuilderConnections.BaseNegativeConditioning"/></item>
/// <item><see cref="ComfyNodeBuilder.NodeBuilderConnections.Base"/> - Conditioning</item>
/// </list>
/// </summary>
public void ApplyStep(ModuleApplyStepEventArgs e)
@ -91,90 +87,44 @@ public partial class PromptCardViewModel
var negativePrompt = GetNegativePrompt();
negativePrompt.Process();
foreach (var modelConnections in e.Builder.Connections.Models.Values)
{
if (modelConnections.Model is not { } model || modelConnections.Clip is not { } clip)
continue;
// If need to load loras, add a group
if (positivePrompt.ExtraNetworks.Count > 0)
{
var loras = positivePrompt.GetExtraNetworksAsLocalModels(modelIndexService).ToList();
// Add group to load loras onto model and clip in series
var lorasGroup = e.Builder.Group_LoraLoadMany(
"Loras",
e.Builder.Connections.BaseModel ?? throw new ArgumentException("BaseModel is null"),
e.Builder.Connections.BaseClip ?? throw new ArgumentException("BaseClip is null"),
loras
);
// Set last outputs as base model and clip
e.Builder.Connections.BaseModel = lorasGroup.Output1;
e.Builder.Connections.BaseClip = lorasGroup.Output2;
// Refiner loras
if (e.Builder.Connections.RefinerModel is not null)
{
// Add group to load loras onto refiner model and clip in series
var lorasGroupRefiner = e.Builder.Group_LoraLoadMany(
"Refiner_Loras",
e.Builder.Connections.RefinerModel
?? throw new ArgumentException("RefinerModel is null"),
e.Builder.Connections.RefinerClip
?? throw new ArgumentException("RefinerClip is null"),
loras
);
// Add group to load loras onto model and clip in series
var lorasGroup = e.Builder.Group_LoraLoadMany($"Loras_{modelConnections.Name}", model, clip, loras);
// Set last outputs as refiner model and clip
e.Builder.Connections.RefinerModel = lorasGroupRefiner.Output1;
e.Builder.Connections.RefinerClip = lorasGroupRefiner.Output2;
}
// Set last outputs as model and clip
modelConnections.Model = lorasGroup.Output1;
modelConnections.Clip = lorasGroup.Output2;
}
// Clips
var positiveClip = e.Builder.Nodes.AddTypedNode(
var positiveClip = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "PositiveCLIP",
Clip = e.Builder.Connections.BaseClip!,
Name = $"PositiveCLIP_{modelConnections.Name}",
Clip = e.Builder.Connections.Base.Clip!,
Text = positivePrompt.ProcessedText
}
);
var negativeClip = e.Builder.Nodes.AddTypedNode(
var negativeClip = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "NegativeCLIP",
Clip = e.Builder.Connections.BaseClip!,
Name = $"NegativeCLIP_{modelConnections.Name}",
Clip = e.Builder.Connections.Base.Clip!,
Text = negativePrompt.ProcessedText
}
);
// Set base conditioning from Clips
e.Builder.Connections.BaseConditioning = positiveClip.Output;
e.Builder.Connections.BaseNegativeConditioning = negativeClip.Output;
// Refiner Clips
if (e.Builder.Connections.RefinerModel is not null)
{
var positiveClipRefiner = e.Builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "Refiner_PositiveCLIP",
Clip =
e.Builder.Connections.RefinerClip
?? throw new ArgumentException("RefinerClip is null"),
Text = positivePrompt.ProcessedText
}
);
var negativeClipRefiner = e.Builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "Refiner_NegativeCLIP",
Clip =
e.Builder.Connections.RefinerClip
?? throw new ArgumentException("RefinerClip is null"),
Text = negativePrompt.ProcessedText
}
);
// Set refiner conditioning from Clips
e.Builder.Connections.RefinerConditioning = positiveClipRefiner.Output;
e.Builder.Connections.RefinerNegativeConditioning = negativeClipRefiner.Output;
// Set conditioning from Clips
modelConnections.Conditioning = (positiveClip.Output, negativeClip.Output);
}
}
@ -283,11 +233,7 @@ public partial class PromptCardViewModel
```
""";
var dialog = DialogHelper.CreateMarkdownDialog(
md,
"Prompt Syntax",
TextEditorPreset.Prompt
);
var dialog = DialogHelper.CreateMarkdownDialog(md, "Prompt Syntax", TextEditorPreset.Prompt);
dialog.MinDialogWidth = 800;
dialog.MaxDialogHeight = 1000;
dialog.MaxDialogWidth = 1000;
@ -305,9 +251,7 @@ public partial class PromptCardViewModel
}
catch (PromptError e)
{
await DialogHelper
.CreatePromptErrorDialog(e, prompt.RawText, modelIndexService)
.ShowAsync();
await DialogHelper.CreatePromptErrorDialog(e, prompt.RawText, modelIndexService).ShowAsync();
return;
}
@ -327,10 +271,7 @@ public partial class PromptCardViewModel
builder.AppendLine($"## Networks ({networks.Count}):");
builder.AppendLine("```csharp");
builder.AppendLine(
JsonSerializer.Serialize(
networks,
new JsonSerializerOptions() { WriteIndented = true, }
)
JsonSerializer.Serialize(networks, new JsonSerializerOptions() { WriteIndented = true, })
);
builder.AppendLine("```");
}
@ -378,11 +319,7 @@ public partial class PromptCardViewModel
public override JsonObject SaveStateToJsonObject()
{
return SerializeModel(
new PromptCardModel
{
Prompt = PromptDocument.Text,
NegativePrompt = NegativePromptDocument.Text
}
new PromptCardModel { Prompt = PromptDocument.Text, NegativePrompt = NegativePromptDocument.Text }
);
}
@ -405,10 +342,6 @@ public partial class PromptCardViewModel
/// <inheritdoc />
public GenerationParameters SaveStateToParameters(GenerationParameters parameters)
{
return parameters with
{
PositivePrompt = PromptDocument.Text,
NegativePrompt = NegativePromptDocument.Text
};
return parameters with { PositivePrompt = PromptDocument.Text, NegativePrompt = NegativePromptDocument.Text };
}
}

66
StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs

@ -12,6 +12,7 @@ using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Inference.Modules;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy;
@ -122,14 +123,8 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
}
// Provide temp values
e.Temp.Conditioning = (
e.Builder.Connections.BaseConditioning!,
e.Builder.Connections.BaseNegativeConditioning!
);
e.Temp.RefinerConditioning = (
e.Builder.Connections.RefinerConditioning!,
e.Builder.Connections.RefinerNegativeConditioning!
);
e.Temp.Conditioning = e.Builder.Connections.Base.Conditioning;
e.Temp.RefinerConditioning = e.Builder.Connections.Refiner.Conditioning;
// Apply steps from our addons
ApplyAddonSteps(e);
@ -153,16 +148,17 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
var primaryLatent = e.Builder.GetPrimaryAsLatent();
// Set primary sampler and scheduler
e.Builder.Connections.PrimarySampler =
SelectedSampler ?? throw new ValidationException("Sampler not selected");
e.Builder.Connections.PrimaryScheduler =
SelectedScheduler ?? throw new ValidationException("Scheduler not selected");
var primarySampler = SelectedSampler ?? throw new ValidationException("Sampler not selected");
e.Builder.Connections.PrimarySampler = primarySampler;
var primaryScheduler = SelectedScheduler ?? throw new ValidationException("Scheduler not selected");
e.Builder.Connections.PrimaryScheduler = primaryScheduler;
// Use custom sampler if SDTurbo scheduler is selected
if (e.Builder.Connections.PrimaryScheduler == ComfyScheduler.SDTurbo)
{
// Error if using refiner
if (e.Builder.Connections.RefinerModel is not null)
if (e.Builder.Connections.Refiner.Model is not null)
{
throw new ValidationException("SDTurbo Scheduler cannot be used with Refiner Model");
}
@ -179,7 +175,7 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
new ComfyNodeBuilder.SDTurboScheduler
{
Name = "SDTurboScheduler",
Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"),
Model = e.Builder.Connections.Base.Model.Unwrap(),
Steps = Steps,
Denoise = DenoiseStrength
}
@ -189,7 +185,7 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
new ComfyNodeBuilder.SamplerCustom
{
Name = "Sampler",
Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"),
Model = e.Builder.Connections.Base.Model,
AddNoise = true,
NoiseSeed = e.Builder.Connections.Seed,
Cfg = CfgScale,
@ -207,21 +203,23 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
}
// Use KSampler if no refiner, otherwise need KSamplerAdvanced
if (e.Builder.Connections.RefinerModel is null)
if (e.Builder.Connections.Refiner.Model is null)
{
var baseConditioning = e.Builder.Connections.Base.Conditioning.Unwrap();
// No refiner
var sampler = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.KSampler
{
Name = "Sampler",
Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"),
Model = e.Builder.Connections.Base.Model.Unwrap(),
Seed = e.Builder.Connections.Seed,
SamplerName = e.Builder.Connections.PrimarySampler?.Name!,
Scheduler = e.Builder.Connections.PrimaryScheduler?.Name!,
SamplerName = primarySampler.Name,
Scheduler = primaryScheduler.Name,
Steps = Steps,
Cfg = CfgScale,
Positive = e.Temp.Conditioning?.Positive!,
Negative = e.Temp.Conditioning?.Negative!,
Positive = baseConditioning.Positive,
Negative = baseConditioning.Negative,
LatentImage = primaryLatent,
Denoise = DenoiseStrength,
}
@ -231,20 +229,23 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
}
else
{
var baseConditioning = e.Builder.Connections.Base.Conditioning.Unwrap();
var refinerConditioning = e.Builder.Connections.Refiner.Conditioning.Unwrap();
// Advanced base sampler for refiner
var sampler = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.KSamplerAdvanced
{
Name = "Sampler",
Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"),
Model = e.Builder.Connections.Base.Model.Unwrap(),
AddNoise = true,
NoiseSeed = e.Builder.Connections.Seed,
Steps = TotalSteps,
Cfg = CfgScale,
SamplerName = e.Builder.Connections.PrimarySampler?.Name!,
Scheduler = e.Builder.Connections.PrimaryScheduler?.Name!,
Positive = e.Temp.Conditioning?.Positive!,
Negative = e.Temp.Conditioning?.Negative!,
SamplerName = primarySampler.Name,
Scheduler = primaryScheduler.Name,
Positive = baseConditioning.Positive,
Negative = baseConditioning.Negative,
LatentImage = primaryLatent,
StartAtStep = 0,
EndAtStep = Steps,
@ -256,17 +257,16 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo
var refinerSampler = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.KSamplerAdvanced
{
Name = "Refiner_Sampler",
Model =
e.Builder.Connections.RefinerModel ?? throw new ArgumentException("No RefinerModel"),
Name = "Sampler_Refiner",
Model = e.Builder.Connections.Refiner.Model,
AddNoise = false,
NoiseSeed = e.Builder.Connections.Seed,
Steps = TotalSteps,
Cfg = CfgScale,
SamplerName = e.Builder.Connections.PrimarySampler?.Name!,
Scheduler = e.Builder.Connections.PrimaryScheduler?.Name!,
Positive = e.Temp.RefinerConditioning?.Positive!,
Negative = e.Temp.RefinerConditioning?.Negative!,
SamplerName = primarySampler.Name,
Scheduler = primaryScheduler.Name,
Positive = refinerConditioning.Positive,
Negative = refinerConditioning.Negative,
// Connect to previous sampler
LatentImage = sampler.Output,
StartAtStep = Steps,

35
StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs

@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
namespace StabilityMatrix.Avalonia.ViewModels.Inference.Video;
[View(typeof(ModelCard))]
[ManagedService]
[Transient]
public class ImgToVidModelCardViewModel : ModelCardViewModel
{
public ImgToVidModelCardViewModel(IInferenceClientManager clientManager)
: base(clientManager)
{
DisableSettings = true;
}
public override void ApplyStep(ModuleApplyStepEventArgs e)
{
var imgToVidLoader = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.ImageOnlyCheckpointLoader
{
Name = "ImageOnlyCheckpointLoader",
CkptName = SelectedModel?.RelativePath ?? throw new ValidationException("Model not selected")
}
);
e.Builder.Connections.Base.Model = imgToVidLoader.Output1;
e.Builder.Connections.BaseClipVision = imgToVidLoader.Output2;
e.Builder.Connections.Base.VAE = imgToVidLoader.Output3;
}
}

104
StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs

@ -0,0 +1,104 @@
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes;
namespace StabilityMatrix.Avalonia.ViewModels.Inference.Video;
[View(typeof(VideoGenerationSettingsCard))]
[ManagedService]
[Transient]
public partial class SvdImgToVidConditioningViewModel
: LoadableViewModelBase,
IParametersLoadableState,
IComfyStep
{
[ObservableProperty]
private int width = 1024;
[ObservableProperty]
private int height = 576;
[ObservableProperty]
private int numFrames = 14;
[ObservableProperty]
private int motionBucketId = 127;
[ObservableProperty]
private int fps = 6;
[ObservableProperty]
private double augmentationLevel;
[ObservableProperty]
private double minCfg = 1.0d;
public void LoadStateFromParameters(GenerationParameters parameters)
{
Width = parameters.Width;
Height = parameters.Height;
NumFrames = parameters.FrameCount;
MotionBucketId = parameters.MotionBucketId;
Fps = parameters.Fps;
AugmentationLevel = parameters.AugmentationLevel;
MinCfg = parameters.MinCfg;
}
public GenerationParameters SaveStateToParameters(GenerationParameters parameters)
{
return parameters with
{
FrameCount = NumFrames,
MotionBucketId = MotionBucketId,
Fps = Fps,
AugmentationLevel = AugmentationLevel,
MinCfg = MinCfg,
};
}
public void ApplyStep(ModuleApplyStepEventArgs e)
{
// do VideoLinearCFGGuidance stuff first
var cfgGuidanceNode = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.VideoLinearCFGGuidance
{
Name = e.Nodes.GetUniqueName("LinearCfgGuidance"),
Model =
e.Builder.Connections.Base.Model ?? throw new ValidationException("Model not selected"),
MinCfg = MinCfg
}
);
e.Builder.Connections.Base.Model = cfgGuidanceNode.Output;
// then do the SVD stuff
var svdImgToVidConditioningNode = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.SVD_img2vid_Conditioning
{
ClipVision = e.Builder.Connections.BaseClipVision!,
InitImage = e.Builder.GetPrimaryAsImage(),
Vae = e.Builder.Connections.Base.VAE!,
Name = e.Nodes.GetUniqueName("SvdImgToVidConditioning"),
Width = Width,
Height = Height,
VideoFrames = NumFrames,
MotionBucketId = MotionBucketId,
Fps = Fps,
AugmentationLevel = AugmentationLevel
}
);
e.Builder.Connections.Base.Conditioning = new ConditioningConnections(
svdImgToVidConditioningNode.Output1,
svdImgToVidConditioningNode.Output2
);
e.Builder.Connections.Primary = svdImgToVidConditioningNode.Output3;
}
}

94
StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
namespace StabilityMatrix.Avalonia.ViewModels.Inference.Video;
[View(typeof(VideoOutputSettingsCard))]
[ManagedService]
[Transient]
public partial class VideoOutputSettingsCardViewModel
: LoadableViewModelBase,
IParametersLoadableState,
IComfyStep
{
[ObservableProperty]
private double fps = 6;
[ObservableProperty]
private bool lossless = true;
[ObservableProperty]
private int quality = 85;
[ObservableProperty]
private VideoOutputMethod selectedMethod = VideoOutputMethod.Default;
[ObservableProperty]
private List<VideoOutputMethod> availableMethods = Enum.GetValues<VideoOutputMethod>().ToList();
public void LoadStateFromParameters(GenerationParameters parameters)
{
Fps = parameters.OutputFps;
Lossless = parameters.Lossless;
Quality = parameters.VideoQuality;
if (string.IsNullOrWhiteSpace(parameters.VideoOutputMethod))
return;
SelectedMethod = Enum.TryParse<VideoOutputMethod>(parameters.VideoOutputMethod, true, out var method)
? method
: VideoOutputMethod.Default;
}
public GenerationParameters SaveStateToParameters(GenerationParameters parameters)
{
return parameters with
{
OutputFps = Fps,
Lossless = Lossless,
VideoQuality = Quality,
VideoOutputMethod = SelectedMethod.ToString(),
};
}
public void ApplyStep(ModuleApplyStepEventArgs e)
{
if (e.Builder.Connections.Primary is null)
throw new ArgumentException("No Primary");
var image = e.Builder.Connections.Primary.Match(
_ =>
e.Builder.GetPrimaryAsImage(
e.Builder.Connections.PrimaryVAE
?? e.Builder.Connections.Refiner.VAE
?? e.Builder.Connections.Base.VAE
?? throw new ArgumentException("No Primary, Refiner, or Base VAE")
),
image => image
);
var outputStep = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.SaveAnimatedWEBP
{
Name = e.Nodes.GetUniqueName("SaveAnimatedWEBP"),
Images = image,
FilenamePrefix = "InferenceVideo",
Fps = Fps,
Lossless = Lossless,
Quality = Quality,
Method = SelectedMethod.ToString().ToLowerInvariant()
}
);
e.Builder.Connections.OutputNodes.Add(outputStep);
}
}

107
StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs

@ -53,7 +53,8 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
private readonly ILiteDbContext liteDbContext;
public override string Title => "Inference";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.AppGeneric, IsFilled = true };
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.AppGeneric, IsFilled = true };
public RefreshBadgeViewModel ConnectionBadge { get; } =
new()
@ -110,6 +111,8 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
// "Send to Inference"
EventManager.Instance.InferenceTextToImageRequested += OnInferenceTextToImageRequested;
EventManager.Instance.InferenceUpscaleRequested += OnInferenceUpscaleRequested;
EventManager.Instance.InferenceImageToImageRequested += OnInferenceImageToImageRequested;
EventManager.Instance.InferenceImageToVideoRequested += OnInferenceImageToVideoRequested;
MenuSaveAsCommand.WithConditionalNotificationErrorHandler(notificationService);
MenuOpenProjectCommand.WithConditionalNotificationErrorHandler(notificationService);
@ -126,9 +129,7 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
IDisposable? onStartupComplete = null;
Dispatcher
.UIThread
.Post(() =>
Dispatcher.UIThread.Post(() =>
{
if (e.CurrentPackagePair?.BasePackage is ComfyUI package)
{
@ -138,9 +139,7 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
.Take(1)
.Subscribe(_ =>
{
Dispatcher
.UIThread
.Post(() =>
Dispatcher.UIThread.Post(() =>
{
if (ConnectCommand.CanExecute(null))
{
@ -216,9 +215,14 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
);
// Set not open
await liteDbContext
.InferenceProjects
.UpdateAsync(project with { IsOpen = false, IsSelected = false, CurrentTabIndex = -1 });
await liteDbContext.InferenceProjects.UpdateAsync(
project with
{
IsOpen = false,
IsSelected = false,
CurrentTabIndex = -1
}
);
}
}
}
@ -249,6 +253,16 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
Dispatcher.UIThread.Post(() => AddUpscalerTabFromImage(e).SafeFireAndForget());
}
private void OnInferenceImageToImageRequested(object? sender, LocalImageFile e)
{
Dispatcher.UIThread.Post(() => AddImageToImageFromImage(e).SafeFireAndForget());
}
private void OnInferenceImageToVideoRequested(object? sender, LocalImageFile e)
{
Dispatcher.UIThread.Post(() => AddImageToVideoFromImage(e).SafeFireAndForget());
}
/// <summary>
/// Update the database with current tabs
/// </summary>
@ -272,7 +286,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
entry.IsOpen = tab == SelectedTab;
entry.CurrentTabIndex = i;
Logger.Trace("SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}", tab.TabTitle, entry);
Logger.Trace(
"SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}",
tab.TabTitle,
entry
);
await liteDbContext.InferenceProjects.UpsertAsync(entry);
}
}
@ -287,7 +305,9 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
return;
}
var entry = await liteDbContext.InferenceProjects.FindOneAsync(p => p.FilePath == projectFile.ToString());
var entry = await liteDbContext.InferenceProjects.FindOneAsync(
p => p.FilePath == projectFile.ToString()
);
// Create if not found
entry ??= new InferenceProjectEntry { Id = Guid.NewGuid(), FilePath = projectFile.ToString() };
@ -295,7 +315,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
entry.IsOpen = tab == SelectedTab;
entry.CurrentTabIndex = Tabs.IndexOf(tab);
Logger.Trace("SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}", tab.TabTitle, entry);
Logger.Trace(
"SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}",
tab.TabTitle,
entry
);
await liteDbContext.InferenceProjects.UpsertAsync(entry);
}
@ -408,7 +432,10 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
return;
}
await notificationService.TryAsync(ClientManager.CloseAsync(), "Could not disconnect from ComfyUI backend");
await notificationService.TryAsync(
ClientManager.CloseAsync(),
"Could not disconnect from ComfyUI backend"
);
}
/// <summary>
@ -464,7 +491,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
await using var stream = await result.OpenWriteAsync();
stream.SetLength(0); // Overwrite fully
await JsonSerializer.SerializeAsync(stream, document, new JsonSerializerOptions { WriteIndented = true });
await JsonSerializer.SerializeAsync(
stream,
document,
new JsonSerializerOptions { WriteIndented = true }
);
}
catch (Exception e)
{
@ -512,7 +543,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
await using var stream = projectFile.Info.OpenWrite();
stream.SetLength(0); // Overwrite fully
await JsonSerializer.SerializeAsync(stream, document, new JsonSerializerOptions { WriteIndented = true });
await JsonSerializer.SerializeAsync(
stream,
document,
new JsonSerializerOptions { WriteIndented = true }
);
}
catch (Exception e)
{
@ -624,6 +659,46 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
await SyncTabStatesWithDatabase();
}
private async Task AddImageToImageFromImage(LocalImageFile imageFile)
{
var imgToImgVm = vmFactory.Get<InferenceImageToImageViewModel>();
if (!imageFile.FileName.EndsWith("webp"))
{
imgToImgVm.SelectImageCardViewModel.ImageSource = new ImageSource(imageFile.AbsolutePath);
}
if (imageFile.GenerationParameters != null)
{
imgToImgVm.LoadStateFromParameters(imageFile.GenerationParameters);
}
Tabs.Add(imgToImgVm);
SelectedTab = imgToImgVm;
await SyncTabStatesWithDatabase();
}
private async Task AddImageToVideoFromImage(LocalImageFile imageFile)
{
var imgToVidVm = vmFactory.Get<InferenceImageToVideoViewModel>();
if (imageFile.GenerationParameters != null && imageFile.FileName.EndsWith("webp"))
{
imgToVidVm.LoadStateFromParameters(imageFile.GenerationParameters);
}
if (!imageFile.FileName.EndsWith("webp"))
{
imgToVidVm.SelectImageCardViewModel.ImageSource = new ImageSource(imageFile.AbsolutePath);
}
Tabs.Add(imgToVidVm);
SelectedTab = imgToVidVm;
await SyncTabStatesWithDatabase();
}
/// <summary>
/// Menu "Open Project" command.
/// </summary>

11
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
@ -10,6 +11,7 @@ using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using KGySoft.CoreLibraries;
using NLog;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
@ -112,10 +114,7 @@ public partial class MainWindowViewModel : ViewModelBase
var startupTime = CodeTimer.FormatTime(Program.StartupTimer.Elapsed);
Logger.Info($"App started ({startupTime})");
if (
Program.Args.DebugOneClickInstall
|| settingsManager.Settings.InstalledPackages.Count == 0
)
if (Program.Args.DebugOneClickInstall || settingsManager.Settings.InstalledPackages.Count == 0)
{
var viewModel = dialogFactory.Get<OneClickInstallViewModel>();
var dialog = new BetterContentDialog
@ -148,8 +147,8 @@ public partial class MainWindowViewModel : ViewModelBase
.Where(p => p.GetType().GetCustomAttributes(typeof(PreloadAttribute), true).Any())
)
{
Dispatcher.UIThread
.InvokeAsync(
Dispatcher
.UIThread.InvokeAsync(
async () =>
{
var stopwatch = Stopwatch.StartNew();

67
StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs

@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using DynamicData;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(NewPackageManagerPage))]
[Singleton]
public partial class NewPackageManagerViewModel : PageViewModelBase
{
public override string Title => "Packages";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true };
public IReadOnlyList<PageViewModelBase> SubPages { get; }
[ObservableProperty]
private ObservableCollection<PageViewModelBase> currentPagePath = [];
[ObservableProperty]
private PageViewModelBase? currentPage;
public NewPackageManagerViewModel(ServiceManager<ViewModelBase> vmFactory)
{
SubPages = new PageViewModelBase[]
{
vmFactory.Get<PackageManagerViewModel>(),
vmFactory.Get<PackageInstallBrowserViewModel>(),
};
CurrentPagePath.AddRange(SubPages);
CurrentPage = SubPages[0];
}
partial void OnCurrentPageChanged(PageViewModelBase? value)
{
if (value is null)
{
return;
}
if (value is PackageManagerViewModel)
{
CurrentPagePath.Clear();
CurrentPagePath.Add(value);
}
else if (value is PackageInstallDetailViewModel)
{
CurrentPagePath.Add(value);
}
else
{
CurrentPagePath.Clear();
CurrentPagePath.AddRange(new[] { SubPages[0], value });
}
}
}

22
StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs

@ -93,6 +93,8 @@ public partial class OutputsPageViewModel : PageViewModelBase
? Resources.Label_OneImageSelected
: string.Format(Resources.Label_NumImagesSelected, NumItemsSelected);
private string[] allowedExtensions = [".png", ".webp"];
public OutputsPageViewModel(
ISettingsManager settingsManager,
IPackageFactory packageFactory,
@ -313,6 +315,18 @@ public partial class OutputsPageViewModel : PageViewModelBase
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)
@ -434,11 +448,14 @@ public partial class OutputsPageViewModel : PageViewModelBase
var directory = category.Tag.ToString();
foreach (var path in Directory.EnumerateFiles(directory, "*.png", SearchOption.AllDirectories))
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;
@ -496,7 +513,8 @@ public partial class OutputsPageViewModel : PageViewModelBase
}
var files = Directory
.EnumerateFiles(directory, "*.png", SearchOption.AllDirectories)
.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories)
.Where(path => allowedExtensions.Contains(new FilePath(path).Extension))
.Select(file => LocalImageFile.FromPath(file))
.ToList();

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

@ -97,7 +97,10 @@ public partial class PackageCardViewModel : ProgressViewModel
if (string.IsNullOrWhiteSpace(value?.PackageName))
return;
if (value.PackageName == UnknownPackage.Key || packageFactory.FindPackageByName(value.PackageName) is null)
if (
value.PackageName == UnknownPackage.Key
|| packageFactory.FindPackageByName(value.PackageName) is null
)
{
IsUnknownPackage = true;
CardImageSource = "";
@ -124,7 +127,11 @@ public partial class PackageCardViewModel : ProgressViewModel
if (Design.IsDesignMode || !settingsManager.IsLibraryDirSet || Package is not { } currentPackage)
return;
if (packageFactory.FindPackageByName(currentPackage.PackageName) is { } basePackage and not UnknownPackage)
if (
packageFactory.FindPackageByName(currentPackage.PackageName)
is { } basePackage
and not UnknownPackage
)
{
// Migrate old packages with null preferred shared folder method
currentPackage.PreferredSharedFolderMethod ??= basePackage.RecommendedSharedFolderMethod;
@ -185,11 +192,18 @@ public partial class PackageCardViewModel : ProgressViewModel
var packagePath = new DirectoryPath(settingsManager.LibraryDir, Package.LibraryPath);
var deleteTask = packagePath.DeleteVerboseAsync(logger);
var taskResult = await notificationService.TryAsync(deleteTask, Resources.Text_SomeFilesCouldNotBeDeleted);
var taskResult = await notificationService.TryAsync(
deleteTask,
Resources.Text_SomeFilesCouldNotBeDeleted
);
if (taskResult.IsSuccessful)
{
notificationService.Show(
new Notification(Resources.Label_PackageUninstalled, Package.DisplayName, NotificationType.Success)
new Notification(
Resources.Label_PackageUninstalled,
Package.DisplayName,
NotificationType.Success
)
);
if (!IsUnknownPackage)
@ -254,7 +268,12 @@ public partial class PackageCardViewModel : ProgressViewModel
versionOptions.CommitHash = latest.Sha;
}
var updatePackageStep = new UpdatePackageStep(settingsManager, Package, versionOptions, basePackage);
var updatePackageStep = new UpdatePackageStep(
settingsManager,
Package,
versionOptions,
basePackage
);
var steps = new List<IPackageStep> { updatePackageStep };
EventManager.Instance.OnPackageInstallProgressAdded(runner);
@ -381,7 +400,8 @@ public partial class PackageCardViewModel : ProgressViewModel
if (basePackage == null)
return false;
var canCheckUpdate = Package.LastUpdateCheck == null || Package.LastUpdateCheck < DateTime.Now.AddMinutes(-15);
var canCheckUpdate =
Package.LastUpdateCheck == null || Package.LastUpdateCheck < DateTime.Now.AddMinutes(-15);
if (!canCheckUpdate)
{

143
StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs

@ -0,0 +1,143 @@
using System;
using System.Reactive.Linq;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DynamicData;
using DynamicData.Alias;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.PackageManager;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.PackageManager;
[View(typeof(PackageInstallBrowserView))]
[Transient, ManagedService]
public partial class PackageInstallBrowserViewModel : PageViewModelBase
{
private readonly INavigationService<NewPackageManagerViewModel> packageNavigationService;
private readonly ISettingsManager settingsManager;
private readonly INotificationService notificationService;
private readonly ILogger<PackageInstallDetailViewModel> logger;
private readonly IPyRunner pyRunner;
private readonly IPrerequisiteHelper prerequisiteHelper;
[ObservableProperty]
private bool showIncompatiblePackages;
[ObservableProperty]
private string searchFilter = string.Empty;
private SourceCache<BasePackage, string> packageSource = new(p => p.GithubUrl);
public IObservableCollection<BasePackage> InferencePackages { get; } =
new ObservableCollectionExtended<BasePackage>();
public IObservableCollection<BasePackage> TrainingPackages { get; } =
new ObservableCollectionExtended<BasePackage>();
public PackageInstallBrowserViewModel(
IPackageFactory packageFactory,
INavigationService<NewPackageManagerViewModel> packageNavigationService,
ISettingsManager settingsManager,
INotificationService notificationService,
ILogger<PackageInstallDetailViewModel> logger,
IPyRunner pyRunner,
IPrerequisiteHelper prerequisiteHelper
)
{
this.packageNavigationService = packageNavigationService;
this.settingsManager = settingsManager;
this.notificationService = notificationService;
this.logger = logger;
this.pyRunner = pyRunner;
this.prerequisiteHelper = prerequisiteHelper;
var incompatiblePredicate = this.WhenPropertyChanged(vm => vm.ShowIncompatiblePackages)
.Select(_ => new Func<BasePackage, bool>(p => p.IsCompatible || ShowIncompatiblePackages))
.AsObservable();
var searchPredicate = this.WhenPropertyChanged(vm => vm.SearchFilter)
.Select(
_ =>
new Func<BasePackage, bool>(
p => p.DisplayName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)
)
)
.AsObservable();
packageSource
.Connect()
.DeferUntilLoaded()
.Filter(incompatiblePredicate)
.Filter(searchPredicate)
.Where(p => p is { PackageType: PackageType.SdInference })
.Sort(
SortExpressionComparer<BasePackage>
.Ascending(p => p.InstallerSortOrder)
.ThenByAscending(p => p.DisplayName)
)
.Bind(InferencePackages)
.Subscribe();
packageSource
.Connect()
.DeferUntilLoaded()
.Filter(incompatiblePredicate)
.Filter(searchPredicate)
.Where(p => p is { PackageType: PackageType.SdTraining })
.Sort(
SortExpressionComparer<BasePackage>
.Ascending(p => p.InstallerSortOrder)
.ThenByAscending(p => p.DisplayName)
)
.Bind(TrainingPackages)
.Subscribe();
packageSource.EditDiff(
packageFactory.GetAllAvailablePackages(),
(a, b) => a.GithubUrl == b.GithubUrl
);
}
public override string Title => "Add Package";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Add };
public void OnPackageSelected(BasePackage? package)
{
if (package is null)
{
return;
}
var vm = new PackageInstallDetailViewModel(
package,
settingsManager,
notificationService,
logger,
pyRunner,
prerequisiteHelper,
packageNavigationService
);
Dispatcher.UIThread.Post(
() => packageNavigationService.NavigateTo(vm, BetterSlideNavigationTransition.PageSlideFromRight),
DispatcherPriority.Send
);
}
public void ClearSearchQuery()
{
SearchFilter = string.Empty;
}
}

254
StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs

@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using PackageInstallDetailView = StabilityMatrix.Avalonia.Views.PackageManager.PackageInstallDetailView;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.PackageManager;
[View(typeof(PackageInstallDetailView))]
public partial class PackageInstallDetailViewModel(
BasePackage package,
ISettingsManager settingsManager,
INotificationService notificationService,
ILogger<PackageInstallDetailViewModel> logger,
IPyRunner pyRunner,
IPrerequisiteHelper prerequisiteHelper,
INavigationService<NewPackageManagerViewModel> packageNavigationService
) : PageViewModelBase
{
public BasePackage SelectedPackage { get; } = package;
public override string Title { get; } = package.DisplayName;
public override IconSource IconSource => new SymbolIconSource();
public string FullInstallPath => Path.Combine(settingsManager.LibraryDir, "Packages", InstallName);
public bool ShowReleaseMode => SelectedPackage.ShouldIgnoreReleases == false;
public string ReleaseLabelText => IsReleaseMode ? Resources.Label_Version : Resources.Label_Branch;
public bool ShowTorchVersionOptions => SelectedTorchVersion != TorchVersion.None;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullInstallPath))]
private string installName = package.DisplayName;
[ObservableProperty]
private bool showDuplicateWarning;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ReleaseLabelText))]
private bool isReleaseMode;
[ObservableProperty]
private IEnumerable<PackageVersion> availableVersions = new List<PackageVersion>();
[ObservableProperty]
private PackageVersion? selectedVersion;
[ObservableProperty]
private SharedFolderMethod selectedSharedFolderMethod;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowTorchVersionOptions))]
private TorchVersion selectedTorchVersion;
[ObservableProperty]
private ObservableCollection<GitCommit>? availableCommits;
[ObservableProperty]
private GitCommit? selectedCommit;
private PackageVersionOptions? allOptions;
public override async Task OnLoadedAsync()
{
if (Design.IsDesignMode)
return;
OnInstallNameChanged(InstallName);
allOptions = await SelectedPackage.GetAllVersionOptions();
if (ShowReleaseMode)
{
IsReleaseMode = true;
}
else
{
UpdateVersions();
await UpdateCommits(SelectedPackage.MainBranch);
}
SelectedTorchVersion = SelectedPackage.GetRecommendedTorchVersion();
SelectedSharedFolderMethod = SelectedPackage.RecommendedSharedFolderMethod;
}
[RelayCommand]
private async Task Install()
{
if (string.IsNullOrWhiteSpace(InstallName))
{
notificationService.Show(
new Notification(
"Package name is empty",
"Please enter a name for the package",
NotificationType.Error
)
);
return;
}
var setPackageInstallingStep = new SetPackageInstallingStep(settingsManager, InstallName);
var installLocation = Path.Combine(settingsManager.LibraryDir, "Packages", InstallName);
if (Directory.Exists(installLocation))
{
var installPath = new DirectoryPath(installLocation);
await installPath.DeleteVerboseAsync(logger);
}
var prereqStep = new SetupPrerequisitesStep(prerequisiteHelper, pyRunner);
var downloadOptions = new DownloadPackageVersionOptions();
var installedVersion = new InstalledPackageVersion();
if (IsReleaseMode)
{
downloadOptions.VersionTag =
SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null");
downloadOptions.IsLatest = AvailableVersions?.First().TagName == downloadOptions.VersionTag;
downloadOptions.IsPrerelease = SelectedVersion.IsPrerelease;
installedVersion.InstalledReleaseVersion = downloadOptions.VersionTag;
installedVersion.IsPrerelease = SelectedVersion.IsPrerelease;
}
else
{
downloadOptions.CommitHash =
SelectedCommit?.Sha ?? throw new NullReferenceException("Selected commit is null");
downloadOptions.BranchName =
SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null");
downloadOptions.IsLatest = AvailableCommits?.First().Sha == SelectedCommit.Sha;
installedVersion.InstalledBranch =
SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null");
installedVersion.InstalledCommitSha = downloadOptions.CommitHash;
}
var downloadStep = new DownloadPackageVersionStep(SelectedPackage, installLocation, downloadOptions);
var installStep = new InstallPackageStep(
SelectedPackage,
SelectedTorchVersion,
SelectedSharedFolderMethod,
downloadOptions,
installLocation
);
var setupModelFoldersStep = new SetupModelFoldersStep(
SelectedPackage,
SelectedSharedFolderMethod,
installLocation
);
var package = new InstalledPackage
{
DisplayName = InstallName,
LibraryPath = Path.Combine("Packages", InstallName),
Id = Guid.NewGuid(),
PackageName = SelectedPackage.Name,
Version = installedVersion,
LaunchCommand = SelectedPackage.LaunchCommand,
LastUpdateCheck = DateTimeOffset.Now,
PreferredTorchVersion = SelectedTorchVersion,
PreferredSharedFolderMethod = SelectedSharedFolderMethod
};
var addInstalledPackageStep = new AddInstalledPackageStep(settingsManager, package);
var steps = new List<IPackageStep>
{
setPackageInstallingStep,
prereqStep,
downloadStep,
installStep,
setupModelFoldersStep,
addInstalledPackageStep
};
var runner = new PackageModificationRunner { ShowDialogOnStart = true };
EventManager.Instance.OnPackageInstallProgressAdded(runner);
await runner.ExecuteSteps(steps.ToList());
if (!runner.Failed)
{
EventManager.Instance.OnInstalledPackagesChanged();
notificationService.Show(
"Package Install Complete",
$"{InstallName} installed successfully",
NotificationType.Success
);
}
}
private void UpdateVersions()
{
AvailableVersions =
IsReleaseMode && ShowReleaseMode ? allOptions.AvailableVersions : allOptions.AvailableBranches;
SelectedVersion = !IsReleaseMode
? AvailableVersions?.FirstOrDefault(x => x.TagName == SelectedPackage.MainBranch)
?? AvailableVersions?.FirstOrDefault()
: AvailableVersions?.FirstOrDefault();
}
private async Task UpdateCommits(string branchName)
{
var commits = await SelectedPackage.GetAllCommits(branchName);
if (commits != null)
{
AvailableCommits = new ObservableCollection<GitCommit>(commits);
SelectedCommit = AvailableCommits.FirstOrDefault();
}
}
partial void OnInstallNameChanged(string? value)
{
ShowDuplicateWarning = settingsManager.Settings.InstalledPackages.Any(
p => p.LibraryPath == $"Packages{Path.DirectorySeparatorChar}{value}"
);
}
partial void OnIsReleaseModeChanged(bool value)
{
UpdateVersions();
}
partial void OnSelectedVersionChanged(PackageVersion? value)
{
if (IsReleaseMode)
return;
UpdateCommits(value?.TagName ?? SelectedPackage.MainBranch).SafeFireAndForget();
}
}

72
StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs

@ -9,10 +9,12 @@ using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using DynamicData;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
@ -37,17 +39,17 @@ namespace StabilityMatrix.Avalonia.ViewModels;
/// </summary>
[View(typeof(PackageManagerPage))]
[Singleton]
[Singleton, ManagedService]
public partial class PackageManagerViewModel : PageViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService;
private readonly INavigationService<NewPackageManagerViewModel> packageNavigationService;
private readonly ILogger<PackageManagerViewModel> logger;
public override string Title => "Packages";
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true };
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true };
/// <summary>
/// List of installed packages
@ -71,12 +73,14 @@ public partial class PackageManagerViewModel : PageViewModelBase
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService,
INavigationService<NewPackageManagerViewModel> packageNavigationService,
ILogger<PackageManagerViewModel> logger
)
{
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.notificationService = notificationService;
this.packageNavigationService = packageNavigationService;
this.logger = logger;
EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged;
@ -118,10 +122,7 @@ public partial class PackageManagerViewModel : PageViewModelBase
if (Design.IsDesignMode)
return;
installedPackages.EditDiff(
settingsManager.Settings.InstalledPackages,
InstalledPackage.Comparer
);
installedPackages.EditDiff(settingsManager.Settings.InstalledPackages, InstalledPackage.Comparer);
var currentUnknown = await Task.Run(IndexUnknownPackages);
unknownInstalledPackages.Edit(s => s.Load(currentUnknown));
@ -135,43 +136,9 @@ public partial class PackageManagerViewModel : PageViewModelBase
base.OnUnloaded();
}
public async Task ShowInstallDialog(BasePackage? selectedPackage = null)
{
var viewModel = dialogFactory.Get<InstallerViewModel>();
viewModel.SelectedPackage = selectedPackage ?? viewModel.AvailablePackages[0];
var dialog = new BetterContentDialog
{
MaxDialogWidth = 900,
MinDialogWidth = 900,
FullSizeDesired = true,
DefaultButton = ContentDialogButton.Close,
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
Content = new InstallerDialog { DataContext = viewModel }
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var runner = new PackageModificationRunner { ShowDialogOnStart = true };
var steps = viewModel.Steps;
EventManager.Instance.OnPackageInstallProgressAdded(runner);
await runner.ExecuteSteps(steps.ToList());
if (!runner.Failed)
public void ShowInstallDialog(BasePackage? selectedPackage = null)
{
EventManager.Instance.OnInstalledPackagesChanged();
notificationService.Show(
"Package Install Complete",
$"{viewModel.InstallName} installed successfully",
NotificationType.Success
);
}
}
NavigateToSubPage(typeof(PackageInstallBrowserViewModel));
}
private async Task CheckPackagesForUpdates()
@ -204,11 +171,7 @@ public partial class PackageManagerViewModel : PageViewModelBase
var currentPackages = settingsManager.Settings.InstalledPackages.ToImmutableArray();
foreach (
var subDir in packageDir.Info
.EnumerateDirectories()
.Select(info => new DirectoryPath(info))
)
foreach (var subDir in packageDir.Info.EnumerateDirectories().Select(info => new DirectoryPath(info)))
{
var expectedLibraryPath = $"Packages{Path.DirectorySeparatorChar}{subDir.Name}";
@ -227,6 +190,19 @@ public partial class PackageManagerViewModel : PageViewModelBase
}
}
[RelayCommand]
private void NavigateToSubPage(Type viewModelType)
{
Dispatcher.UIThread.Post(
() =>
packageNavigationService.NavigateTo(
viewModelType,
BetterSlideNavigationTransition.PageSlideFromRight
),
DispatcherPriority.Send
);
}
private void OnInstalledPackagesChanged(object? sender, EventArgs e) =>
OnLoadedAsync().SafeFireAndForget();
}

139
StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs

@ -24,7 +24,9 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using ExifLibrary;
using FluentAvalonia.UI.Controls;
using MetadataExtractor.Formats.Exif;
using NLog;
using SkiaSharp;
using StabilityMatrix.Avalonia.Animations;
@ -46,6 +48,7 @@ using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.HardwareInfo;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Python;
@ -74,8 +77,11 @@ public partial class MainSettingsViewModel : PageViewModelBase
public SharedState SharedState { get; }
public bool IsMacOS => Compat.IsMacOS;
public override string Title => "Settings";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
// ReSharper disable once MemberCanBeMadeStatic.Global
public string AppVersion =>
@ -132,7 +138,8 @@ public partial class MainSettingsViewModel : PageViewModelBase
[ObservableProperty]
private MemoryInfo memoryInfo;
private readonly DispatcherTimer hardwareInfoUpdateTimer = new() { Interval = TimeSpan.FromSeconds(2.627) };
private readonly DispatcherTimer hardwareInfoUpdateTimer =
new() { Interval = TimeSpan.FromSeconds(2.627) };
public Task<CpuInfo> CpuInfoAsync => HardwareHelper.GetCpuInfoAsync();
@ -198,8 +205,16 @@ public partial class MainSettingsViewModel : PageViewModelBase
true
);
settingsManager.RelayPropertyFor(this, vm => vm.SelectedAnimationScale, settings => settings.AnimationScale);
settingsManager.RelayPropertyFor(this, vm => vm.HolidayModeSetting, settings => settings.HolidayModeSetting);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedAnimationScale,
settings => settings.AnimationScale
);
settingsManager.RelayPropertyFor(
this,
vm => vm.HolidayModeSetting,
settings => settings.HolidayModeSetting
);
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn);
@ -239,6 +254,12 @@ public partial class MainSettingsViewModel : PageViewModelBase
{
MemoryInfo = newMemoryInfo;
}
// Stop timer if live memory info is not available
if (!HardwareHelper.IsLiveMemoryUsageInfoAvailable)
{
(sender as DispatcherTimer)?.Stop();
}
}
partial void OnSelectedThemeChanged(string? value)
@ -277,9 +298,7 @@ public partial class MainSettingsViewModel : PageViewModelBase
CloseButtonText = Resources.Action_RelaunchLater
};
Dispatcher
.UIThread
.InvokeAsync(async () =>
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
@ -313,9 +332,7 @@ public partial class MainSettingsViewModel : PageViewModelBase
[RelayCommand]
private void NavigateToSubPage(Type viewModelType)
{
Dispatcher
.UIThread
.Post(
Dispatcher.UIThread.Post(
() =>
settingsNavigationService.NavigateTo(
viewModelType,
@ -350,8 +367,7 @@ public partial class MainSettingsViewModel : PageViewModelBase
{
// Save settings
var newEnvVars = viewModel
.EnvVars
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.EnvVars.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.GroupBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal);
settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars);
@ -405,7 +421,10 @@ public partial class MainSettingsViewModel : PageViewModelBase
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs");
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
@ -744,6 +763,100 @@ public partial class MainSettingsViewModel : PageViewModelBase
}
#endregion
#region Debug Commands
public CommandItem[] DebugCommands =>
[new CommandItem(DebugFindLocalModelFromIndexCommand), new CommandItem(DebugExtractDmgCommand)];
[RelayCommand]
private async Task DebugFindLocalModelFromIndex()
{
var textFields = new TextBoxField[]
{
new() { Label = "Blake3 Hash" },
new() { Label = "SharedFolderType" }
};
var dialog = DialogHelper.CreateTextEntryDialog("Find Local Model", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Func<Task<IEnumerable<LocalModelFile>>> modelGetter;
if (textFields.ElementAtOrDefault(0)?.Text is { } hash && !string.IsNullOrWhiteSpace(hash))
{
modelGetter = () => modelIndexService.FindByHashAsync(hash);
}
else if (textFields.ElementAtOrDefault(1)?.Text is { } type && !string.IsNullOrWhiteSpace(type))
{
modelGetter = () => modelIndexService.FindAsync(Enum.Parse<SharedFolderType>(type));
}
else
{
return;
}
var timer = Stopwatch.StartNew();
var result = (await modelGetter()).ToImmutableArray();
timer.Stop();
if (result.Length != 0)
{
await DialogHelper
.CreateMarkdownDialog(
string.Join(
"\n\n",
result.Select(
(model, i) =>
$"[{i + 1}] {model.RelativePath.ToRepr()} "
+ $"({model.DisplayModelName}, {model.DisplayModelVersion})"
)
),
$"Found Models ({CodeTimer.FormatTime(timer.Elapsed)})"
)
.ShowAsync();
}
else
{
await DialogHelper
.CreateMarkdownDialog(":(", $"No models found ({CodeTimer.FormatTime(timer.Elapsed)})")
.ShowAsync();
}
}
}
[RelayCommand(CanExecute = nameof(IsMacOS))]
private async Task DebugExtractDmg()
{
if (!Compat.IsMacOS)
return;
// Select File
var files = await App.StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions { Title = "Select .dmg file" }
);
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } dmgFile)
return;
// Select output directory
var folders = await App.StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions { Title = "Select output directory" }
);
if (folders.FirstOrDefault()?.TryGetLocalPath() is not { } outputDir)
return;
// Extract
notificationService.Show("Extracting...", dmgFile);
await ArchiveHelper.ExtractDmg(dmgFile, outputDir);
notificationService.Show("Extraction Complete", dmgFile);
}
#endregion
#region Info Section
public void OnVersionClick()

17
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml

@ -61,15 +61,19 @@
<!-- 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 OpenOnCivitAiCommand}"
Text="{x:Static lang:Resources.Action_OpenOnCivitAi}" IconSource="Link"
IsVisible="{Binding IsConnectedModel}" />
<ui:MenuFlyoutItem Command="{Binding CopyTriggerWordsCommand}"
IsVisible="{Binding CanShowTriggerWords}"
Text="{x:Static lang:Resources.Action_CopyTriggerWords}"
IconSource="Copy" />
<ui:MenuFlyoutSeparator IsVisible="{Binding CanShowTriggerWords}"/>
<ui:MenuFlyoutItem Command="{Binding OpenOnCivitAiCommand}"
Text="{x:Static lang:Resources.Action_OpenOnCivitAi}" IconSource="Link"
IsVisible="{Binding IsConnectedModel}" />
<ui:MenuFlyoutItem
Command="{Binding CopyModelUrlCommand}"
Text="Copy Link to Clipboard"
IconSource="Clipboard"
IsVisible="{Binding IsConnectedModel}"/>
<ui:MenuFlyoutItem Text="{x:Static lang:Resources.Label_FindConnectedMetadata}"
IconSource="Find"
Command="{Binding FindConnectedMetadataCommand}"
@ -86,6 +90,9 @@
<system:Boolean>True</system:Boolean>
</ui:MenuFlyoutItem.CommandParameter>
</ui:MenuFlyoutItem>
<ui:MenuFlyoutSeparator/>
<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" />

7
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs

@ -1,6 +1,5 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
@ -44,7 +43,8 @@ public partial class CheckpointsPage : UserControlBase
if (DataContext is CheckpointsPageViewModel vm)
{
subscription = vm.WhenPropertyChanged(m => m.ShowConnectedModelImages).Subscribe(_ => InvalidateRepeater());
subscription = vm.WhenPropertyChanged(m => m.ShowConnectedModelImages)
.Subscribe(_ => InvalidateRepeater());
}
}
@ -152,7 +152,8 @@ public partial class CheckpointsPage : UserControlBase
var parentFolder = file.ParentFolder;
var dragFilePath = new FilePath(dragFile.FilePath);
parentFolder.IsCurrentDragTarget = dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath;
parentFolder.IsCurrentDragTarget =
dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath;
break;
}
}

1
StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs

@ -3,7 +3,6 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Core.Attributes;
using CivitAiBrowserViewModel = StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser.CivitAiBrowserViewModel;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save