Browse Source

Merge branch 'dev' into downmerge

pull/438/head
JT 11 months ago committed by GitHub
parent
commit
447854c107
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. 266
      StabilityMatrix.Avalonia/App.axaml.cs
  33. 28
      StabilityMatrix.Avalonia/Assets.cs
  34. BIN
      StabilityMatrix.Avalonia/Assets/AppIcon.icns
  35. 49
      StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml
  36. 9
      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. 165
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  45. 17
      StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs
  46. 47
      StabilityMatrix.Avalonia/DialogHelper.cs
  47. 67
      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. 44
      StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml
  65. 199
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
  66. 54
      StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
  67. 62
      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. 102
      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. 125
      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. 167
      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. 2
      StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
  98. 165
      StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
  99. 17
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  100. 7
      StabilityMatrix.Avalonia/Views/CheckpointsPage.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"/>

266
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,22 +161,19 @@ public sealed class App : Application
DesktopLifetime.MainWindow = setupWindow;
setupWindow
.ShowAsyncCts
.Token
.Register(() =>
setupWindow.ShowAsyncCts.Token.Register(() =>
{
if (setupWindow.Result == ContentDialogResult.Primary)
{
settingsManager.SetEulaAccepted();
ShowMainWindow();
DesktopLifetime.MainWindow.Show();
}
else
{
if (setupWindow.Result == ContentDialogResult.Primary)
{
settingsManager.SetEulaAccepted();
ShowMainWindow();
DesktopLifetime.MainWindow.Show();
}
else
{
Shutdown();
}
});
Shutdown();
}
});
}
else
{
@ -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,91 +583,79 @@ 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);
}
}
catch (InvalidOperationException)
{
// Ignore in case already shutting down
}
}
else
{
Environment.Exit(exitCode);
}
}
/// <summary>
/// Handle shutdown requests (happens before <see cref="OnExit"/>)
/// </summary>
private static void OnMainWindowClosing(object? sender, WindowClosingEventArgs e)
private static void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e)
{
if (e.Cancel)
return;
var mainWindow = (MainWindow)sender!;
// Show confirmation if package running
var launchPageViewModel = Services.GetRequiredService<LaunchPageViewModel>();
launchPageViewModel.OnMainWindowClosing(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
)
{
// Cancel shutdown for now
e.Cancel = true;
isAsyncDisposeComplete = true;
return;
Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables");
// Cancel shutdown for now
e.Cancel = true;
isAsyncDisposeComplete = true;
Task.Run(async () =>
Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables");
Dispatcher
.UIThread.InvokeAsync(async () =>
{
foreach (var disposable in asyncDisposables)
{
foreach (var disposable in asyncDisposables)
Debug.WriteLine($"Disposing IAsyncDisposable ({disposable.GetType().Name})");
try
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine($"Disposing IAsyncDisposable ({disposable.GetType().Name})");
try
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.Fail(ex.ToString());
}
Debug.Fail(ex.ToString());
}
})
.ContinueWith(_ =>
}
})
.ContinueWith(_ =>
{
// Shutdown again
Debug.WriteLine("Finished disposing IAsyncDisposables, shutting down");
if (Dispatcher.UIThread.SupportsRunLoops)
{
// Shutdown again
Dispatcher.UIThread.Invoke(() => Shutdown());
})
.SafeFireAndForget();
return;
}
OnMainWindowClosingTerminal(mainWindow);
}
/// <summary>
/// Called at the end of <see cref="OnMainWindowClosing"/> before the main window is closed.
/// </summary>
private static void OnMainWindowClosingTerminal(Window sender)
{
var settingsManager = Services.GetRequiredService<ISettingsManager>();
// Save window position
var validWindowPosition = sender.Screens.All.Any(screen => screen.Bounds.Contains(sender.Position));
}
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,20 +749,18 @@ public sealed class App : Application
// Sentry
if (SentrySdk.IsEnabled)
{
LogManager
.Configuration
.AddSentry(o =>
{
o.InitializeSdk = false;
o.Layout = "${message}";
o.ShutdownTimeoutSeconds = 5;
o.IncludeEventDataOnBreadcrumbs = true;
o.BreadcrumbLayout = "${logger}: ${message}";
// Debug and higher are stored as breadcrumbs (default is Info)
o.MinimumBreadcrumbLevel = NLog.LogLevel.Debug;
// Error and higher is sent as event (default is Error)
o.MinimumEventLevel = NLog.LogLevel.Error;
});
LogManager.Configuration.AddSentry(o =>
{
o.InitializeSdk = false;
o.Layout = "${message}";
o.ShutdownTimeoutSeconds = 5;
o.IncludeEventDataOnBreadcrumbs = true;
o.BreadcrumbLayout = "${logger}: ${message}";
// Debug and higher are stored as breadcrumbs (default is Info)
o.MinimumBreadcrumbLevel = NLog.LogLevel.Debug;
// Error and higher is sent as event (default is Error)
o.MinimumEventLevel = NLog.LogLevel.Error;
});
}
LogManager.ReconfigExistingLoggers();
@ -803,34 +799,36 @@ public sealed class App : Application
results.Add(ms);
}
Dispatcher
.UIThread
.InvokeAsync(async () =>
{
var dest = await StorageProvider.SaveFilePickerAsync(
new FilePickerSaveOptions() { SuggestedFileName = "screenshot.png", ShowOverwritePrompt = true }
);
Dispatcher.UIThread.InvokeAsync(async () =>
{
var dest = await StorageProvider.SaveFilePickerAsync(
new FilePickerSaveOptions()
{
SuggestedFileName = "screenshot.png",
ShowOverwritePrompt = true
}
);
if (dest?.TryGetLocalPath() is { } localPath)
if (dest?.TryGetLocalPath() is { } localPath)
{
var localFile = new FilePath(localPath);
foreach (var (i, stream) in results.Enumerate())
{
var localFile = new FilePath(localPath);
foreach (var (i, stream) in results.Enumerate())
var name = localFile.NameWithoutExtension;
if (results.Count > 1)
{
var name = localFile.NameWithoutExtension;
if (results.Count > 1)
{
name += $"_{i + 1}";
}
localFile = localFile.Directory!.JoinFile(name + ".png");
localFile.Create();
await using var fileStream = localFile.Info.OpenWrite();
stream.Seek(0, SeekOrigin.Begin);
await stream.CopyToAsync(fileStream);
name += $"_{i + 1}";
}
localFile = localFile.Directory!.JoinFile(name + ".png");
localFile.Create();
await using var fileStream = localFile.Info.OpenWrite();
stream.Seek(0, SeekOrigin.Begin);
await stream.CopyToAsync(fileStream);
}
});
}
});
}
[Conditional("DEBUG")]

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.

49
StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml

@ -8,30 +8,43 @@
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>
<controls:AdvancedImageBox
Name="ImageBox"
CornerRadius="4"
Image="{Binding BitmapAsync^}"
SizeMode="Fit">
<controls:AdvancedImageBox.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem
x:Name="CopyMenuItem"
CommandParameter="{Binding #ImageBox.Image}"
HotKey="Ctrl+C"
IconSource="Copy"
IsEnabled="{OnPlatform Windows=True,
Default=False}"
Text="Copy" />
</ui:FAMenuFlyout>
</controls:AdvancedImageBox.ContextFlyout>
</controls:AdvancedImageBox>
<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
CornerRadius="4"
Image="{Binding BitmapAsync^}"
SizeMode="Fit">
<controls:AdvancedImageBox.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem
x:Name="CopyMenuItem"
CommandParameter="{Binding $parent[controls:AdvancedImageBox].Image}"
HotKey="Ctrl+C"
IconSource="Copy"
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

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

@ -21,13 +21,16 @@ public partial class AdvancedImageBoxView : UserControl
{
base.OnApplyTemplate(e);
var copyMenuItem = this.FindControl<MenuFlyoutItem>("CopyMenuItem")!;
copyMenuItem.Command = new AsyncRelayCommand<Bitmap?>(FlyoutCopy);
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 { }

165
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,25 +384,27 @@ public static class DesignData
new() { FilePath = "~/Models/Lora/model.safetensors", Title = "Some model" }
};
ProgressManagerViewModel
.ProgressItems
.AddRange(
new ProgressItemViewModelBase[]
{
new ProgressItemViewModel(
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"
),
new PackageInstallProgressItemViewModel(
new PackageModificationRunner
{
CurrentProgress = new ProgressReport(0.5f, "Installing package...")
}
ProgressManagerViewModel.ProgressItems.AddRange(
new ProgressItemViewModelBase[]
{
new ProgressItemViewModel(
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"
),
new PackageInstallProgressItemViewModel(
new PackageModificationRunner
{
CurrentProgress = new ProgressReport(0.5f, "Installing package...")
}
)
}
);
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.CurrentVersionText = "v2.0.0";
@ -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;

67
StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs

@ -20,15 +20,17 @@ public static class ComfyNodeBuilderExtensions
int? batchIndex = null
)
{
var emptyLatent = builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.EmptyLatentImage
{
Name = "EmptyLatentImage",
BatchSize = batchSize,
Height = height,
Width = width
}
);
var emptyLatent = builder
.Nodes
.AddTypedNode(
new ComfyNodeBuilder.EmptyLatentImage
{
Name = "EmptyLatentImage",
BatchSize = batchSize,
Height = height,
Width = width
}
);
builder.Connections.Primary = emptyLatent.Output;
builder.Connections.PrimarySize = new Size(width, height);
@ -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,24 +97,25 @@ public static class ComfyNodeBuilderExtensions
if (builder.Connections.Primary is null)
throw new ArgumentException("No Primary");
var image = builder.Connections.Primary.Match(
_ =>
builder.GetPrimaryAsImage(
builder.Connections.PrimaryVAE
?? builder.Connections.RefinerVAE
?? builder.Connections.BaseVAE
?? throw new ArgumentException("No Primary, Refiner, or Base VAE")
),
image => image
);
var image = builder
.Connections
.Primary
.Match(
_ =>
builder.GetPrimaryAsImage(
builder.Connections.PrimaryVAE
?? 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>

44
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>
</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>

199
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();
@ -271,17 +290,13 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
Task.Run(
async () =>
{
try
var delayTime = 250 - (int)timer.ElapsedMilliseconds;
if (delayTime > 0)
{
var delayTime = 250 - (int)timer.ElapsedMilliseconds;
if (delayTime > 0)
{
await Task.Delay(delayTime, cancellationToken);
}
// ReSharper disable once AccessToDisposedClosure
AttachRunningNodeChangedHandler(promptTask);
await Task.Delay(delayTime, cancellationToken);
}
catch (TaskCanceledException) { }
// ReSharper disable once AccessToDisposedClosure
AttachRunningNodeChangedHandler(promptTask);
},
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,14 +409,64 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
);
}
var bytesWithMetadata = PngDataHelper.AddMetadata(imageArray, parameters, project);
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
);
// Write using generated name
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(".", "")
);
EventManager.Instance.OnImageFileAdded(filePath);
outputImages.Add(new ImageSource(filePath));
EventManager.Instance.OnImageFileAdded(filePath);
}
}
// Download all images to make grid, if multiple
@ -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,17 +568,15 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
/// </summary>
protected virtual void OnProgressUpdateReceived(object? sender, ComfyProgressUpdateEventArgs args)
{
Dispatcher
.UIThread
.Post(() =>
{
OutputProgress.Value = args.Value;
OutputProgress.Maximum = args.Maximum;
OutputProgress.IsIndeterminate = false;
Dispatcher.UIThread.Post(() =>
{
OutputProgress.Value = args.Value;
OutputProgress.Maximum = args.Maximum;
OutputProgress.IsIndeterminate = false;
OutputProgress.Text =
$"({args.Value} / {args.Maximum})" + (args.RunningNode != null ? $" {args.RunningNode}" : "");
});
OutputProgress.Text =
$"({args.Value} / {args.Maximum})" + (args.RunningNode != null ? $" {args.RunningNode}" : "");
});
}
private void AttachRunningNodeChangedHandler(ComfyTask comfyTask)
@ -548,15 +601,13 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie
return;
}
Dispatcher
.UIThread
.Post(() =>
{
OutputProgress.IsIndeterminate = true;
OutputProgress.Value = 100;
OutputProgress.Maximum = 100;
OutputProgress.Text = nodeName;
});
Dispatcher.UIThread.Post(() =>
{
OutputProgress.IsIndeterminate = true;
OutputProgress.Value = 100;
OutputProgress.Maximum = 100;
OutputProgress.Text = nodeName;
});
}
public class ImageGenerationEventArgs : EventArgs

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>

62
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,30 +121,24 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
var latestVersionInstalled =
latestVersion.Files != null
&& latestVersion
.Files
.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModels.Contains(file.Hashes.BLAKE3)
);
&& latestVersion.Files.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModels.Contains(file.Hashes.BLAKE3)
);
// check if any of the ModelVersion.Files.Hashes.BLAKE3 hashes are in the installedModels list
var anyVersionInstalled =
latestVersionInstalled
|| CivitModel
.ModelVersions
.Any(
version =>
version.Files != null
&& version
.Files
.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModels.Contains(file.Hashes.BLAKE3)
)
);
|| CivitModel.ModelVersions.Any(
version =>
version.Files != null
&& version.Files.Any(
file =>
file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
&& installedModels.Contains(file.Hashes.BLAKE3)
)
);
UpdateCardText = latestVersionInstalled
? "Installed"
@ -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);

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

@ -2,11 +2,16 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
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;
@ -20,6 +25,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 +63,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 +113,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
partial void OnSelectedFileChanged(CivitFileViewModel? value)
{
if (value is { IsInstalled: true }) { }
var canImport = true;
if (settingsManager.IsLibraryDirSet)
{
@ -133,6 +149,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
}

125
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();
// If need to load loras, add a group
if (positivePrompt.ExtraNetworks.Count > 0)
foreach (var modelConnections in e.Builder.Connections.Models.Values)
{
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;
if (modelConnections.Model is not { } model || modelConnections.Clip is not { } clip)
continue;
// Refiner loras
if (e.Builder.Connections.RefinerModel is not null)
// If need to load loras, add a group
if (positivePrompt.ExtraNetworks.Count > 0)
{
// 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
);
var loras = positivePrompt.GetExtraNetworksAsLocalModels(modelIndexService).ToList();
// Set last outputs as refiner model and clip
e.Builder.Connections.RefinerModel = lorasGroupRefiner.Output1;
e.Builder.Connections.RefinerClip = lorasGroupRefiner.Output2;
}
}
// Add group to load loras onto model and clip in series
var lorasGroup = e.Builder.Group_LoraLoadMany($"Loras_{modelConnections.Name}", model, clip, loras);
// Clips
var positiveClip = e.Builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "PositiveCLIP",
Clip = e.Builder.Connections.BaseClip!,
Text = positivePrompt.ProcessedText
}
);
var negativeClip = e.Builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "NegativeCLIP",
Clip = e.Builder.Connections.BaseClip!,
Text = negativePrompt.ProcessedText
// Set last outputs as model and clip
modelConnections.Model = lorasGroup.Output1;
modelConnections.Clip = lorasGroup.Output2;
}
);
// 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(
// Clips
var positiveClip = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "Refiner_PositiveCLIP",
Clip =
e.Builder.Connections.RefinerClip
?? throw new ArgumentException("RefinerClip is null"),
Name = $"PositiveCLIP_{modelConnections.Name}",
Clip = e.Builder.Connections.Base.Clip!,
Text = positivePrompt.ProcessedText
}
);
var negativeClipRefiner = e.Builder.Nodes.AddTypedNode(
var negativeClip = e.Nodes.AddTypedNode(
new ComfyNodeBuilder.CLIPTextEncode
{
Name = "Refiner_NegativeCLIP",
Clip =
e.Builder.Connections.RefinerClip
?? throw new ArgumentException("RefinerClip is null"),
Name = $"NegativeCLIP_{modelConnections.Name}",
Clip = e.Builder.Connections.Base.Clip!,
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);
}
}

167
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,47 +129,43 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable
IDisposable? onStartupComplete = null;
Dispatcher
.UIThread
.Post(() =>
Dispatcher.UIThread.Post(() =>
{
if (e.CurrentPackagePair?.BasePackage is ComfyUI package)
{
if (e.CurrentPackagePair?.BasePackage is ComfyUI package)
{
IsWaitingForConnection = true;
onStartupComplete = Observable
.FromEventPattern<string>(package, nameof(package.StartupComplete))
.Take(1)
.Subscribe(_ =>
IsWaitingForConnection = true;
onStartupComplete = Observable
.FromEventPattern<string>(package, nameof(package.StartupComplete))
.Take(1)
.Subscribe(_ =>
{
Dispatcher.UIThread.Post(() =>
{
Dispatcher
.UIThread
.Post(() =>
{
if (ConnectCommand.CanExecute(null))
{
Logger.Trace("On package launch - starting connection");
ConnectCommand.Execute(null);
}
IsWaitingForConnection = false;
});
if (ConnectCommand.CanExecute(null))
{
Logger.Trace("On package launch - starting connection");
ConnectCommand.Execute(null);
}
IsWaitingForConnection = false;
});
}
else
});
}
else
{
// Cancel any pending connection
if (ConnectCancelCommand.CanExecute(null))
{
// Cancel any pending connection
if (ConnectCancelCommand.CanExecute(null))
{
ConnectCancelCommand.Execute(null);
}
onStartupComplete?.Dispose();
onStartupComplete = null;
IsWaitingForConnection = false;
// Disconnect
Logger.Trace("On package close - disconnecting");
DisconnectCommand.Execute(null);
ConnectCancelCommand.Execute(null);
}
});
onStartupComplete?.Dispose();
onStartupComplete = null;
IsWaitingForConnection = false;
// Disconnect
Logger.Trace("On package close - disconnecting");
DisconnectCommand.Execute(null);
}
});
}
public override void OnLoaded()
@ -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)
public void 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)
{
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();
}

2
StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs

@ -169,7 +169,7 @@ public partial class InferenceSettingsViewModel : PageViewModelBase
var files = await storage.OpenFilePickerAsync(
new FilePickerOpenOptions
{
FileTypeFilter = new List<FilePickerFileType> { new("CSV") { Patterns = ["*.csv"] } }
FileTypeFilter = new List<FilePickerFileType> { new("CSV") { Patterns = ["*.csv"] } }
}
);

165
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,16 +298,14 @@ public partial class MainSettingsViewModel : PageViewModelBase
CloseButtonText = Resources.Action_RelaunchLater
};
Dispatcher
.UIThread
.InvokeAsync(async () =>
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
}
else
{
@ -313,16 +332,14 @@ public partial class MainSettingsViewModel : PageViewModelBase
[RelayCommand]
private void NavigateToSubPage(Type viewModelType)
{
Dispatcher
.UIThread
.Post(
() =>
settingsNavigationService.NavigateTo(
viewModelType,
BetterSlideNavigationTransition.PageSlideFromRight
),
DispatcherPriority.Send
);
Dispatcher.UIThread.Post(
() =>
settingsNavigationService.NavigateTo(
viewModelType,
BetterSlideNavigationTransition.PageSlideFromRight
),
DispatcherPriority.Send
);
}
#region Package Environment
@ -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;
}
}

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

Loading…
Cancel
Save