diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 88b5eae3..aeffd47d 100644
--- a/.github/workflows/release.yml
+++ b/.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:
@@ -171,11 +175,122 @@ jobs:
with:
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
diff --git a/Avalonia.Gif/Avalonia.Gif.csproj b/Avalonia.Gif/Avalonia.Gif.csproj
new file mode 100644
index 00000000..1d2f4488
--- /dev/null
+++ b/Avalonia.Gif/Avalonia.Gif.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0
+ latest
+ true
+ win-x64;linux-x64;osx-x64;osx-arm64
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
diff --git a/Avalonia.Gif/BgWorkerCommand.cs b/Avalonia.Gif/BgWorkerCommand.cs
new file mode 100644
index 00000000..aebd44dd
--- /dev/null
+++ b/Avalonia.Gif/BgWorkerCommand.cs
@@ -0,0 +1,10 @@
+namespace Avalonia.Gif
+{
+ internal enum BgWorkerCommand
+ {
+ Null,
+ Play,
+ Pause,
+ Dispose
+ }
+}
diff --git a/Avalonia.Gif/BgWorkerState.cs b/Avalonia.Gif/BgWorkerState.cs
new file mode 100644
index 00000000..1b09bba1
--- /dev/null
+++ b/Avalonia.Gif/BgWorkerState.cs
@@ -0,0 +1,12 @@
+namespace Avalonia.Gif
+{
+ internal enum BgWorkerState
+ {
+ Null,
+ Start,
+ Running,
+ Paused,
+ Complete,
+ Dispose
+ }
+}
diff --git a/Avalonia.Gif/Decoding/BlockTypes.cs b/Avalonia.Gif/Decoding/BlockTypes.cs
new file mode 100644
index 00000000..2d804d5b
--- /dev/null
+++ b/Avalonia.Gif/Decoding/BlockTypes.cs
@@ -0,0 +1,10 @@
+namespace Avalonia.Gif.Decoding
+{
+ internal enum BlockTypes
+ {
+ Empty = 0,
+ Extension = 0x21,
+ ImageDescriptor = 0x2C,
+ Trailer = 0x3B,
+ }
+}
diff --git a/Avalonia.Gif/Decoding/ExtensionType.cs b/Avalonia.Gif/Decoding/ExtensionType.cs
new file mode 100644
index 00000000..5db6d575
--- /dev/null
+++ b/Avalonia.Gif/Decoding/ExtensionType.cs
@@ -0,0 +1,8 @@
+namespace Avalonia.Gif.Decoding
+{
+ internal enum ExtensionType
+ {
+ GraphicsControl = 0xF9,
+ Application = 0xFF
+ }
+}
diff --git a/Avalonia.Gif/Decoding/FrameDisposal.cs b/Avalonia.Gif/Decoding/FrameDisposal.cs
new file mode 100644
index 00000000..bf4f00b7
--- /dev/null
+++ b/Avalonia.Gif/Decoding/FrameDisposal.cs
@@ -0,0 +1,10 @@
+namespace Avalonia.Gif.Decoding
+{
+ public enum FrameDisposal
+ {
+ Unknown = 0,
+ Leave = 1,
+ Background = 2,
+ Restore = 3
+ }
+}
diff --git a/Avalonia.Gif/Decoding/GifColor.cs b/Avalonia.Gif/Decoding/GifColor.cs
new file mode 100644
index 00000000..222d7303
--- /dev/null
+++ b/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;
+
+ ///
+ /// A struct that represents a ARGB color and is aligned as
+ /// a BGRA bytefield in memory.
+ ///
+ /// Red
+ /// Green
+ /// Blue
+ /// Alpha
+ public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue)
+ {
+ A = a;
+ R = r;
+ G = g;
+ B = b;
+ }
+ }
+}
diff --git a/Avalonia.Gif/Decoding/GifDecoder.cs b/Avalonia.Gif/Decoding/GifDecoder.cs
new file mode 100644
index 00000000..adc26bb0
--- /dev/null
+++ b/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 G87AMagic = "GIF87a"u8.ToArray().AsMemory();
+
+ private static readonly ReadOnlyMemory G89AMagic = "GIF89a"u8.ToArray().AsMemory();
+
+ private static readonly ReadOnlyMemory 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 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.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.Shared.Return(tmpB);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void DrawFrame(GifFrame curFrame, Memory 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 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();
+ }
+
+ ///
+ /// Directly copies the struct array to a bitmap IntPtr.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Processes GIF Header.
+ ///
+ private void ProcessHeaderData()
+ {
+ var str = _fileStream;
+ var tmpB = ArrayPool.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.Shared.Return(tmpB);
+ }
+
+ ///
+ /// Parses colors from file stream to target color table.
+ ///
+ 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;
+ }
+
+ ///
+ /// Parses screen and other GIF descriptors.
+ ///
+ 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);
+ }
+
+ ///
+ /// Parses all frame data.
+ ///
+ private void ProcessFrameData()
+ {
+ _fileStream.Position = Header.HeaderSize;
+
+ var tempBuf = ArrayPool.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.Shared.Return(tempBuf);
+ }
+
+ ///
+ /// Parses GIF Image Descriptor Block.
+ ///
+ 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());
+ }
+
+ ///
+ /// Parses GIF Extension Blocks.
+ ///
+ 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;
+ }
+ }
+ }
+}
diff --git a/Avalonia.Gif/Decoding/GifFrame.cs b/Avalonia.Gif/Decoding/GifFrame.cs
new file mode 100644
index 00000000..ea0e6640
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/Avalonia.Gif/Decoding/GifHeader.cs b/Avalonia.Gif/Decoding/GifHeader.cs
new file mode 100644
index 00000000..16638f79
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/Avalonia.Gif/Decoding/GifRect.cs b/Avalonia.Gif/Decoding/GifRect.cs
new file mode 100644
index 00000000..01f621de
--- /dev/null
+++ b/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();
+ }
+ }
+}
diff --git a/Avalonia.Gif/Decoding/GifRepeatBehavior.cs b/Avalonia.Gif/Decoding/GifRepeatBehavior.cs
new file mode 100644
index 00000000..4b27a7bb
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/Avalonia.Gif/Decoding/InvalidGifStreamException.cs b/Avalonia.Gif/Decoding/InvalidGifStreamException.cs
new file mode 100644
index 00000000..b3554bac
--- /dev/null
+++ b/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) { }
+ }
+}
diff --git a/Avalonia.Gif/Decoding/LzwDecompressionException.cs b/Avalonia.Gif/Decoding/LzwDecompressionException.cs
new file mode 100644
index 00000000..ed25c0aa
--- /dev/null
+++ b/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) { }
+ }
+}
diff --git a/Avalonia.Gif/Extensions/StreamExtensions.cs b/Avalonia.Gif/Extensions/StreamExtensions.cs
new file mode 100644
index 00000000..ac08fa68
--- /dev/null
+++ b/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 b) => (ushort)(b[0] | (b[1] << 8));
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Skip(this Stream stream, long count)
+ {
+ stream.Position += count;
+ }
+
+ ///
+ /// Read a Gif block from stream while advancing the position.
+ ///
+ [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;
+ }
+
+ ///
+ /// Skips GIF blocks until it encounters an empty block.
+ ///
+ [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);
+ }
+
+ ///
+ /// Read a from stream by providing a temporary buffer.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static ushort ReadUShortS(this Stream stream, byte[] tempBuf)
+ {
+ stream.Read(tempBuf, 0, 2);
+ return SpanToShort(tempBuf);
+ }
+
+ ///
+ /// Read a from stream by providing a temporary buffer.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static byte ReadByteS(this Stream stream, byte[] tempBuf)
+ {
+ stream.Read(tempBuf, 0, 1);
+ var finalVal = tempBuf[0];
+ return finalVal;
+ }
+ }
+}
diff --git a/Avalonia.Gif/GifImage.cs b/Avalonia.Gif/GifImage.cs
new file mode 100644
index 00000000..0ce8dca8
--- /dev/null
+++ b/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 SourceUriRawProperty = AvaloniaProperty.Register<
+ GifImage,
+ string
+ >("SourceUriRaw");
+
+ public static readonly StyledProperty SourceUriProperty = AvaloniaProperty.Register(
+ "SourceUri"
+ );
+
+ public static readonly StyledProperty SourceStreamProperty = AvaloniaProperty.Register<
+ GifImage,
+ Stream
+ >("SourceStream");
+
+ public static readonly StyledProperty IterationCountProperty = AvaloniaProperty.Register<
+ GifImage,
+ IterationCount
+ >("IterationCount", IterationCount.Infinite);
+
+ private IGifInstance? _gifInstance;
+
+ public static readonly StyledProperty StretchDirectionProperty = AvaloniaProperty.Register<
+ GifImage,
+ StretchDirection
+ >("StretchDirection");
+
+ public static readonly StyledProperty StretchProperty = AvaloniaProperty.Register(
+ "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());
+ }
+ }
+ }
+
+ ///
+ /// Measures the control.
+ ///
+ /// The available size.
+ /// The desired size of the control.
+ 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;
+ }
+
+ ///
+ 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);
+ }
+ }
+}
diff --git a/Avalonia.Gif/GifInstance.cs b/Avalonia.Gif/GifInstance.cs
new file mode 100644
index 00000000..b97badaa
--- /dev/null
+++ b/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 _frameTimes;
+ private uint _iterationCount;
+ private int _currentFrameIndex;
+ private readonly List _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;
+ }
+ }
+}
diff --git a/Avalonia.Gif/IGifInstance.cs b/Avalonia.Gif/IGifInstance.cs
new file mode 100644
index 00000000..667f9163
--- /dev/null
+++ b/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);
+}
diff --git a/Avalonia.Gif/InvalidGifStreamException.cs b/Avalonia.Gif/InvalidGifStreamException.cs
new file mode 100644
index 00000000..9771d9cb
--- /dev/null
+++ b/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) { }
+ }
+}
diff --git a/Avalonia.Gif/WebpInstance.cs b/Avalonia.Gif/WebpInstance.cs
new file mode 100644
index 00000000..b9256934
--- /dev/null
+++ b/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 _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;
+ }
+}
diff --git a/Build/AppEntitlements.entitlements b/Build/AppEntitlements.entitlements
new file mode 100644
index 00000000..885de0b5
--- /dev/null
+++ b/Build/AppEntitlements.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
diff --git a/Build/EmbeddedEntitlements.entitlements b/Build/EmbeddedEntitlements.entitlements
new file mode 100644
index 00000000..c4317486
--- /dev/null
+++ b/Build/EmbeddedEntitlements.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/Build/build_macos_app.sh b/Build/build_macos_app.sh
new file mode 100755
index 00000000..acdfcd4b
--- /dev/null
+++ b/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
diff --git a/Build/codesign_embedded_macos.sh b/Build/codesign_embedded_macos.sh
new file mode 100755
index 00000000..c0a99ea2
--- /dev/null
+++ b/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
diff --git a/Build/codesign_macos.sh b/Build/codesign_macos.sh
new file mode 100755
index 00000000..5a05fc74
--- /dev/null
+++ b/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
diff --git a/Build/notarize_macos.sh b/Build/notarize_macos.sh
new file mode 100755
index 00000000..7fae9ccf
--- /dev/null
+++ b/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"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 903abd15..c7cdbeb1 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/README.md b/README.md
index a5f348dd..27291185 100644
--- a/README.md
+++ b/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
diff --git a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
index eb84e3f3..84e8c3f9 100644
--- a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
+++ b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
@@ -21,6 +21,7 @@
+
diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml
index b3d39771..d3039d44 100644
--- a/StabilityMatrix.Avalonia/App.axaml
+++ b/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">
@@ -23,6 +24,7 @@
+
@@ -58,6 +60,8 @@
+
+
diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs
index 742b10a3..d4bba201 100644
--- a/StabilityMatrix.Avalonia/App.axaml.cs
+++ b/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()
+ .DataValidators.OfType()
.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(),
provider.GetRequiredService(),
- provider.GetRequiredService(),
+ provider.GetRequiredService(),
provider.GetRequiredService(),
provider.GetRequiredService(),
provider.GetRequiredService()
@@ -297,7 +292,9 @@ public sealed class App : Application
var serviceManager = new ServiceManager();
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().ToArray() });
+ .Select(
+ t1 => new { Type = t1.t, Attributes = t1.attributes.Cast().ToArray() }
+ );
foreach (var typePair in singletonTypes)
{
@@ -386,7 +386,9 @@ public sealed class App : Application
// Rich presence
services.AddSingleton();
- services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddSingleton(
+ provider => provider.GetRequiredService()
+ );
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);
}
}
- ///
- /// Handle shutdown requests (happens before )
- ///
- 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.OnMainWindowClosing(e);
+ Debug.WriteLine("Start OnShutdownRequested");
if (e.Cancel)
return;
// Check if we need to dispose IAsyncDisposables
if (
- !isAsyncDisposeComplete
- && Services.GetServices().ToList() is { Count: > 0 } asyncDisposables
+ isAsyncDisposeComplete
+ || Services.GetServices().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);
- }
-
- ///
- /// Called at the end of before the main window is closed.
- ///
- private static void OnMainWindowClosingTerminal(Window sender)
- {
- var settingsManager = Services.GetRequiredService();
-
- // 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")]
diff --git a/StabilityMatrix.Avalonia/Assets.cs b/StabilityMatrix.Avalonia/Assets.cs
index 68f76884..5a51640e 100644
--- a/StabilityMatrix.Avalonia/Assets.cs
+++ b/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");
///
/// Fixed image for models with no images.
///
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
///
/// Yield AvaloniaResources given a relative directory path within the 'Assets' folder.
///
- 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);
diff --git a/StabilityMatrix.Avalonia/Assets/AppIcon.icns b/StabilityMatrix.Avalonia/Assets/AppIcon.icns
new file mode 100644
index 00000000..079c0b1f
Binary files /dev/null and b/StabilityMatrix.Avalonia/Assets/AppIcon.icns differ
diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml
index eb681d53..73b43f25 100644
--- a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml
+++ b/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">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
("CopyMenuItem")!;
- copyMenuItem.Command = new AsyncRelayCommand(FlyoutCopy);
+
+ if (this.FindControl("CopyMenuItem") is { } copyMenuItem)
+ {
+ copyMenuItem.Command = new AsyncRelayCommand(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(() =>
{
diff --git a/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs b/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs
new file mode 100644
index 00000000..7209879c
--- /dev/null
+++ b/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 SelectionBoxItemTemplateProperty =
+ AvaloniaProperty.RegisterDirect(
+ 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);
+ }
+
+ ///
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ if (e.NameScope.Find("ContentPresenter") is { } contentPresenter)
+ {
+ if (SelectionBoxItemTemplate is { } template)
+ {
+ contentPresenter.ContentTemplate = template;
+ }
+ }
+ }
+}
diff --git a/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs b/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs
new file mode 100644
index 00000000..4e9ac24f
--- /dev/null
+++ b/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;
+
+///
+/// Selector for objects implementing
+///
+[PublicAPI]
+public class DataTemplateSelector : IDataTemplate
+ where TKey : notnull
+{
+ ///
+ /// Key that is used when no other key matches
+ ///
+ public TKey? DefaultKey { get; set; }
+
+ [Content]
+ public Dictionary Templates { get; } = new();
+
+ public bool Match(object? data) => data is ITemplateKey;
+
+ ///
+ public Control Build(object? data)
+ {
+ if (data is not ITemplateKey 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));
+ }
+}
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml
index e0fc6a1c..a8b5c941 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml
+++ b/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">
+
@@ -21,126 +24,39 @@
-
+
-
-
-
-
+ SelectedItem="{Binding SelectedModel}"/>
@@ -149,30 +65,28 @@
-
-
+ SelectedItem="{Binding SelectedRefiner}"/>
+
+
+
+
+
+
-
+
diff --git a/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml
new file mode 100644
index 00000000..f9f38ba9
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs
new file mode 100644
index 00000000..70347512
--- /dev/null
+++ b/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 { }
diff --git a/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml
new file mode 100644
index 00000000..c247611a
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs
new file mode 100644
index 00000000..a730a993
--- /dev/null
+++ b/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 { }
diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs
index a1bd2022..9688f93d 100644
--- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs
+++ b/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.AvailablePackages = new ObservableCollectionExtended(
- packageFactory.GetAllAvailablePackages()
+ NewInstallerDialogViewModel = Services.GetRequiredService();
+ // NewInstallerDialogViewModel.InferencePackages = new ObservableCollectionExtended(
+ // packageFactory.GetPackagesByType(PackageType.SdInference).OrderBy(p => p.InstallerSortOrder)
+ // );
+ // NewInstallerDialogViewModel.TrainingPackages = new ObservableCollection(
+ // 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.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 DialogFactory =>
Services.GetRequiredService>();
- public static MainWindowViewModel MainWindowViewModel => Services.GetRequiredService();
+ public static MainWindowViewModel MainWindowViewModel =>
+ Services.GetRequiredService();
public static FirstLaunchSetupViewModel FirstLaunchSetupViewModel =>
Services.GetRequiredService();
- public static LaunchPageViewModel LaunchPageViewModel => Services.GetRequiredService();
+ public static LaunchPageViewModel LaunchPageViewModel =>
+ Services.GetRequiredService();
public static HuggingFacePageViewModel HuggingFacePageViewModel =>
Services.GetRequiredService();
@@ -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();
+ public static NewPackageManagerViewModel NewPackageManagerViewModel =>
+ Services.GetRequiredService();
+
public static InferenceSettingsViewModel InferenceSettingsViewModel =>
Services.GetRequiredService();
- public static MainSettingsViewModel MainSettingsViewModel => Services.GetRequiredService();
+ public static MainSettingsViewModel MainSettingsViewModel =>
+ Services.GetRequiredService();
public static AccountSettingsViewModel AccountSettingsViewModel =>
Services.GetRequiredService();
@@ -556,7 +581,10 @@ The gallery images are often inpainted, but you will get something very similar
}
}
};
- var sampleViewModel = new ModelVersionViewModel(new HashSet { "ABCD" }, sampleCivitVersions[0]);
+ var sampleViewModel = new ModelVersionViewModel(
+ new HashSet { "ABCD" },
+ sampleCivitVersions[0]
+ );
// Sample data for dialogs
vm.Versions = new List { 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(vm =>
+ {
+ vm.OutputProgress.Value = 10;
+ vm.OutputProgress.Maximum = 30;
+ vm.OutputProgress.Text = "Sampler 10/30";
+ });
+
public static InferenceImageToImageViewModel InferenceImageToImageViewModel =>
DialogFactory.Get();
public static InferenceImageUpscaleViewModel InferenceImageUpscaleViewModel =>
DialogFactory.Get();
- public static PackageImportViewModel PackageImportViewModel => DialogFactory.Get();
+ public static PackageImportViewModel PackageImportViewModel =>
+ DialogFactory.Get();
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(vm =>
@@ -681,6 +720,9 @@ The gallery images are often inpainted, but you will get something very similar
public static ModelCardViewModel ModelCardViewModel => DialogFactory.Get();
+ public static ImgToVidModelCardViewModel ImgToVidModelCardViewModel =>
+ DialogFactory.Get();
+
public static ImageGalleryCardViewModel ImageGalleryCardViewModel =>
DialogFactory.Get(vm =>
{
@@ -706,7 +748,8 @@ The gallery images are often inpainted, but you will get something very similar
);
});
- public static ImageFolderCardViewModel ImageFolderCardViewModel => DialogFactory.Get();
+ public static ImageFolderCardViewModel ImageFolderCardViewModel =>
+ DialogFactory.Get();
public static FreeUCardViewModel FreeUCardViewModel => DialogFactory.Get();
@@ -759,7 +802,8 @@ The gallery images are often inpainted, but you will get something very similar
public static UpscalerCardViewModel UpscalerCardViewModel => DialogFactory.Get();
- public static BatchSizeCardViewModel BatchSizeCardViewModel => DialogFactory.Get();
+ public static BatchSizeCardViewModel BatchSizeCardViewModel =>
+ DialogFactory.Get();
public static BatchSizeCardViewModel BatchSizeCardViewModelWithIndexOption =>
DialogFactory.Get(vm =>
@@ -790,6 +834,42 @@ The gallery images are often inpainted, but you will get something very similar
}
}
+ public static IEnumerable 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(vm =>
{
@@ -816,7 +896,8 @@ The gallery images are often inpainted, but you will get something very similar
public static InferenceConnectionHelpViewModel InferenceConnectionHelpViewModel =>
DialogFactory.Get();
- public static SelectImageCardViewModel SelectImageCardViewModel => DialogFactory.Get();
+ public static SelectImageCardViewModel SelectImageCardViewModel =>
+ DialogFactory.Get();
public static SelectImageCardViewModel SelectImageCardViewModel_WithImage =>
DialogFactory.Get(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();
+ public static ControlNetCardViewModel ControlNetCardViewModel =>
+ DialogFactory.Get();
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);
diff --git a/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs b/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs
index 3081441c..92bfc564 100644
--- a/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs
+++ b/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
}
///
- public Task> GetModelsOfType(SharedFolderType type)
+ public Task> FindAsync(SharedFolderType type)
{
- return Task.FromResult>(new List());
+ return Task.FromResult(Enumerable.Empty());
+ }
+
+ ///
+ public Task> FindByHashAsync(string hashBlake3)
+ {
+ return Task.FromResult(Enumerable.Empty());
+ }
+
+ ///
+ public Task RemoveModelAsync(LocalModelFile model)
+ {
+ return Task.FromResult(false);
}
///
diff --git a/StabilityMatrix.Avalonia/DialogHelper.cs b/StabilityMatrix.Avalonia/DialogHelper.cs
index 0cdfeabb..7ad27bcb 100644
--- a/StabilityMatrix.Avalonia/DialogHelper.cs
+++ b/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 textFields
)
{
- return CreateTextEntryDialog(
- title,
- new MarkdownScrollViewer { Markdown = description },
- textFields
- );
+ return CreateTextEntryDialog(title, new MarkdownScrollViewer { Markdown = description }, textFields);
}
///
@@ -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()
+ var firstTextBox = stackPanel
+ .Children.OfType()
.FirstOrDefault()
.FindDescendantOfType();
firstTextBox!.Focus();
@@ -254,16 +242,16 @@ public static class DialogHelper
Content = viewer,
CloseButtonText = Resources.Action_Close,
IsPrimaryButtonEnabled = false,
+ MinDialogWidth = 800,
+ MaxDialogHeight = 1000,
+ MaxDialogWidth = 1000
};
}
///
/// Create a dialog for displaying an ApiException
///
- 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;
diff --git a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs b/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs
index 38c28fb3..a4a88705 100644
--- a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs
+++ b/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);
diff --git a/StabilityMatrix.Avalonia/Helpers/UriHandler.cs b/StabilityMatrix.Avalonia/Helpers/UriHandler.cs
index 75dd4a5f..3b16d078 100644
--- a/StabilityMatrix.Avalonia/Helpers/UriHandler.cs
+++ b/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();
}
}
diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
index 4347bb9b..df668ab0 100644
--- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
+++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
@@ -707,6 +707,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to Augmentation Level.
+ ///
+ public static string Label_AugmentationLevel {
+ get {
+ return ResourceManager.GetString("Label_AugmentationLevel", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Auto Completion.
///
@@ -833,6 +842,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to CLIP Skip.
+ ///
+ public static string Label_CLIPSkip {
+ get {
+ return ResourceManager.GetString("Label_CLIPSkip", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Close dialog when finished.
///
@@ -1184,6 +1202,24 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to Frames Per Second.
+ ///
+ public static string Label_Fps {
+ get {
+ return ResourceManager.GetString("Label_Fps", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Frames.
+ ///
+ public static string Label_Frames {
+ get {
+ return ResourceManager.GetString("Label_Frames", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to General.
///
@@ -1229,6 +1265,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to Image to Video.
+ ///
+ public static string Label_ImageToVideo {
+ get {
+ return ResourceManager.GetString("Label_ImageToVideo", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Image Viewer.
///
@@ -1427,6 +1472,24 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to Lossless.
+ ///
+ public static string Label_Lossless {
+ get {
+ return ResourceManager.GetString("Label_Lossless", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Min CFG.
+ ///
+ public static string Label_MinCfg {
+ get {
+ return ResourceManager.GetString("Label_MinCfg", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Missing Image File.
///
@@ -1481,6 +1544,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to Motion Bucket ID.
+ ///
+ public static string Label_MotionBucketId {
+ get {
+ return ResourceManager.GetString("Label_MotionBucketId", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Networks (Lora / LyCORIS).
///
@@ -2156,6 +2228,24 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
+ ///
+ /// Looks up a localized string similar to Method.
+ ///
+ public static string Label_VideoOutputMethod {
+ get {
+ return ResourceManager.GetString("Label_VideoOutputMethod", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Quality.
+ ///
+ public static string Label_VideoQuality {
+ get {
+ return ResourceManager.GetString("Label_VideoQuality", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Waiting to connect....
///
diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx
index 93cdca80..d9641154 100644
--- a/StabilityMatrix.Avalonia/Languages/Resources.resx
+++ b/StabilityMatrix.Avalonia/Languages/Resources.resx
@@ -897,4 +897,34 @@
Holiday Mode
+
+ CLIP Skip
+
+
+ Image to Video
+
+
+ Frames Per Second
+
+
+ Min CFG
+
+
+ Lossless
+
+
+ Frames
+
+
+ Motion Bucket ID
+
+
+ Augmentation Level
+
+
+ Method
+
+
+ Quality
+
diff --git a/StabilityMatrix.Avalonia/Models/CommandItem.cs b/StabilityMatrix.Avalonia/Models/CommandItem.cs
new file mode 100644
index 00000000..5c2089d9
--- /dev/null
+++ b/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();
+}
diff --git a/StabilityMatrix.Avalonia/Models/ITemplateKey.cs b/StabilityMatrix.Avalonia/Models/ITemplateKey.cs
new file mode 100644
index 00000000..34c998d7
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Models/ITemplateKey.cs
@@ -0,0 +1,11 @@
+using StabilityMatrix.Avalonia.Controls;
+
+namespace StabilityMatrix.Avalonia.Models;
+
+///
+/// Implements a template key for
+///
+public interface ITemplateKey
+{
+ T TemplateKey { get; }
+}
diff --git a/StabilityMatrix.Avalonia/Models/ImageSource.cs b/StabilityMatrix.Avalonia/Models/ImageSource.cs
index 4a8fffb7..f20d316a 100644
--- a/StabilityMatrix.Avalonia/Models/ImageSource.cs
+++ b/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
{
private Hash? contentHashBlake3;
@@ -55,6 +55,23 @@ public record ImageSource : IDisposable
Bitmap = bitmap;
}
+ ///
+ 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 BitmapAsync => GetBitmapAsync();
diff --git a/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs b/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs
new file mode 100644
index 00000000..14ab15d4
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs
@@ -0,0 +1,8 @@
+namespace StabilityMatrix.Avalonia.Models;
+
+public enum ImageSourceTemplateType
+{
+ Default,
+ Image,
+ WebpAnimation
+}
diff --git a/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs b/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs
index f90baa4b..8119abc1 100644
--- a/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs
+++ b/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
///
/// Generation overrides (like hires fix generate, current seed generate, etc.)
///
- public IReadOnlyDictionary IsEnabledOverrides { get; init; } =
- new Dictionary();
+ public IReadOnlyDictionary IsEnabledOverrides { get; init; } = new Dictionary();
public class ModuleApplyStepTemporaryArgs
{
///
/// Temporary conditioning apply step, used by samplers to apply control net.
///
- public (
- ConditioningNodeConnection Positive,
- ConditioningNodeConnection Negative
- )? Conditioning { get; set; }
+ public ConditioningConnections? Conditioning { get; set; }
///
/// Temporary refiner conditioning apply step, used by samplers to apply control net.
///
- public (
- ConditioningNodeConnection Positive,
- ConditioningNodeConnection Negative
- )? RefinerConditioning { get; set; }
+ public ConditioningConnections? RefinerConditioning { get; set; }
///
/// Temporary model apply step, used by samplers to apply control net.
diff --git a/StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs b/StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs
new file mode 100644
index 00000000..134a3c0b
--- /dev/null
+++ b/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,
+}
diff --git a/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs b/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs
index 3b8c1cad..3cc0355e 100644
--- a/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs
+++ b/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()
};
diff --git a/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs b/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs
index eaae74eb..1b4bb017 100644
--- a/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs
+++ b/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)
};
diff --git a/StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs b/StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs
new file mode 100644
index 00000000..18982f9a
--- /dev/null
+++ b/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; }
+}
diff --git a/StabilityMatrix.Avalonia/Services/INavigationService.cs b/StabilityMatrix.Avalonia/Services/INavigationService.cs
index 3f7d0885..f1aa8ba4 100644
--- a/StabilityMatrix.Avalonia/Services/INavigationService.cs
+++ b/StabilityMatrix.Avalonia/Services/INavigationService.cs
@@ -19,10 +19,7 @@ public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypePar
///
/// Navigate to the view of the given view model type.
///
- void NavigateTo(
- NavigationTransitionInfo? transitionInfo = null,
- object? param = null
- )
+ void NavigateTo(NavigationTransitionInfo? transitionInfo = null, object? param = null)
where TViewModel : ViewModelBase;
///
@@ -38,4 +35,6 @@ public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypePar
/// Navigate to the view of the given view model.
///
void NavigateTo(ViewModelBase viewModel, NavigationTransitionInfo? transitionInfo = null);
+
+ bool GoBack();
}
diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs
index 5b4bd630..c95d3068 100644
--- a/StabilityMatrix.Avalonia/Services/NavigationService.cs
+++ b/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),
InterfaceType = typeof(INavigationService)
)]
+[Singleton(
+ ImplType = typeof(NavigationService),
+ InterfaceType = typeof(INavigationService)
+)]
public class NavigationService : INavigationService
{
private Frame? _frame;
@@ -33,10 +39,7 @@ public class NavigationService : INavigationService
}
///
- public void NavigateTo(
- NavigationTransitionInfo? transitionInfo = null,
- object? param = null
- )
+ public void NavigateTo(NavigationTransitionInfo? transitionInfo = null, object? param = null)
where TViewModel : ViewModelBase
{
if (_frame is null)
@@ -70,10 +73,7 @@ public class NavigationService : INavigationService
}
);
- TypedNavigation?.Invoke(
- this,
- new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) }
- );
+ TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) });
}
///
@@ -120,10 +120,7 @@ public class NavigationService : INavigationService
}
);
- TypedNavigation?.Invoke(
- this,
- new TypedNavigationEventArgs { ViewModelType = viewModelType }
- );
+ TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = viewModelType });
}
///
@@ -159,13 +156,36 @@ public class NavigationService : INavigationService
}
);
+ 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;
}
}
diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
index 54e5d85b..51fd37cc 100644
--- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
+++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
@@ -1,5 +1,8 @@
+ Stability Matrix
+ Assets/AppIcon.icns
+ APPL
WinExe
net8.0
win-x64;linux-x64;osx-x64;osx-arm64
@@ -8,12 +11,19 @@
app.manifest
true
./Assets/Icon.ico
- 2.7.0-pre.999
+ 2.8.0-dev.999
$(Version)
true
true
+
+
+ StabilityMatrix.URL
+ stabilitymatrix;stabilitymatrix://
+
+
+
@@ -30,6 +40,7 @@
+
@@ -41,6 +52,7 @@
+
@@ -91,6 +103,7 @@
+
@@ -128,6 +141,12 @@
+
+
+
+ Always
+
+
@@ -142,6 +161,30 @@
True
Resources.resx
+
+ InferenceImageToVideoView.axaml
+ Code
+
+
+ VideoGenerationSettingsCard.axaml
+ Code
+
+
+ VideoOutputSettingsCard.axaml
+ Code
+
+
+ NewPackageManagerPage.axaml
+ Code
+
+
+ PackageInstallDetailView.axaml
+ Code
+
+
+ NewInstallerDialog.axaml
+ Code
+
diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml
new file mode 100644
index 00000000..e2e71476
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml
index d0c2007e..d0c54b78 100644
--- a/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml
+++ b/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml
@@ -2,33 +2,36 @@
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">
-
-
+
+
+
+
-
+
-
-
+ ColumnDefinitions="Auto,*"
+ ColumnSpacing="6"
+ RowSpacing="0">
-
+
-
-
+
+
-
+
-
+
-
+
diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
index 08a41a64..7a50ee01 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
+++ b/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.
///
[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
+ );
}
///
@@ -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?> 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('_', ' '));
- }
- }
-
///
/// Handles image output metadata for generation runs
///
private async Task ProcessOutputImages(
IReadOnlyCollection 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.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
/// Optional overrides (side buttons)
/// Cancellation token
[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
///
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
diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
index 965b787f..fdd9a520 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
+++ b/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 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.");
}
///
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
index 4dc4acbc..afdeb085 100644
--- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
+++ b/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("
", $"{Environment.NewLine}{Environment.NewLine}")
+ .Description?.Replace("
", $"{Environment.NewLine}{Environment.NewLine}")
.Replace("
", $"{Environment.NewLine}{Environment.NewLine}")
.Replace("
", $"{Environment.NewLine}{Environment.NewLine}")
.Replace("", $"{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().GetStringValue());
+ var downloadDirectory = rootModelsDirectory.JoinDir(
+ model.Type.ConvertTo().GetStringValue()
+ );
// Folders might be missing if user didn't install any packages yet
downloadDirectory.Create();
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
index 333a3a02..33ed693d 100644
--- a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
@@ -70,7 +70,14 @@ public partial class CheckpointFile : ViewModelBase
public ObservableCollection 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 FilePathComparer { get; } = new FilePathEqualityComparer();
+ public static IEqualityComparer FilePathComparer { get; } =
+ new FilePathEqualityComparer();
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs
index 34253d7b..f6893400 100644
--- a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs
+++ b/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;
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs
index aad4cc87..fafd1206 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs
+++ b/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(
- param: new PackageManagerPage.PackageManagerNavigationOptions
+ navigationService.NavigateTo(
+ param: new PackageManagerNavigationOptions
{
OpenInstallerDialog = true,
InstallerSelectedPackage = packageFactory
.GetAllAvailablePackages()
- .First(p => p is ComfyUI)
+ .OfType()
+ .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
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs
index c98d41bf..46fd634b 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs
+++ b/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 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 Steps { get; set; }
@@ -148,6 +139,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
AvailablePackages = new ObservableCollection(
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; }
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs
index 17740c40..73922420 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs
+++ b/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 civitFileViewModels;
- [ObservableProperty] private bool isInstalled;
+ private readonly HashSet installedModelHashes;
+
+ [ObservableProperty]
+ private CivitModelVersion modelVersion;
+
+ [ObservableProperty]
+ private ObservableCollection civitFileViewModels;
+
+ [ObservableProperty]
+ private bool isInstalled;
public ModelVersionViewModel(HashSet 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(
- ModelVersion.Files?.Select(file => new CivitFileViewModel(installedModelHashes, file)) ??
- new List());
+ ModelVersion.Files?.Select(file => new CivitFileViewModel(installedModelHashes, file))
+ ?? new List()
+ );
+ }
+
+ public void RefreshInstallStatus()
+ {
+ IsInstalled =
+ ModelVersion.Files?.Any(
+ file =>
+ file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null }
+ && installedModelHashes.Contains(file.Hashes.BLAKE3)
+ ) ?? false;
}
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs
index b7d00f8e..6f00313b 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs
+++ b/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);
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
index b5cb4be9..ef7c2ddf 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
@@ -1,15 +1,22 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.IO;
using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls.Notifications;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
@@ -20,6 +27,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IDownloadService downloadService;
+ private readonly IModelIndexService modelIndexService;
+ private readonly INotificationService notificationService;
public required ContentDialog Dialog { get; set; }
public required IReadOnlyList Versions { get; set; }
@@ -56,10 +65,17 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
public int DisplayedPageNumber => SelectedImageIndex + 1;
- public SelectModelVersionViewModel(ISettingsManager settingsManager, IDownloadService downloadService)
+ public SelectModelVersionViewModel(
+ ISettingsManager settingsManager,
+ IDownloadService downloadService,
+ IModelIndexService modelIndexService,
+ INotificationService notificationService
+ )
{
this.settingsManager = settingsManager;
this.downloadService = downloadService;
+ this.modelIndexService = modelIndexService;
+ this.notificationService = notificationService;
}
public override void OnLoaded()
@@ -99,6 +115,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
partial void OnSelectedFileChanged(CivitFileViewModel? value)
{
+ if (value is { IsInstalled: true }) { }
+
var canImport = true;
if (settingsManager.IsLibraryDirSet)
{
@@ -133,6 +151,90 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
Dialog.Hide(ContentDialogResult.Primary);
}
+ public async Task Delete()
+ {
+ if (SelectedFile == null)
+ return;
+
+ var fileToDelete = SelectedFile;
+ var originalSelectedVersionVm = SelectedVersionViewModel;
+
+ var hash = fileToDelete.CivitFile.Hashes.BLAKE3;
+ if (string.IsNullOrWhiteSpace(hash))
+ {
+ notificationService.Show(
+ "Error deleting file",
+ "Could not delete model, hash is missing.",
+ NotificationType.Error
+ );
+ return;
+ }
+
+ var matchingModels = (await modelIndexService.FindByHashAsync(hash)).ToList();
+
+ if (matchingModels.Count == 0)
+ {
+ await modelIndexService.RefreshIndex();
+ matchingModels = (await modelIndexService.FindByHashAsync(hash)).ToList();
+
+ if (matchingModels.Count == 0)
+ {
+ notificationService.Show(
+ "Error deleting file",
+ "Could not delete model, model not found in index.",
+ NotificationType.Error
+ );
+ return;
+ }
+ }
+
+ var dialog = new BetterContentDialog
+ {
+ Title = Resources.Label_AreYouSure,
+ MaxDialogWidth = 750,
+ MaxDialogHeight = 850,
+ PrimaryButtonText = Resources.Action_Yes,
+ IsPrimaryButtonEnabled = true,
+ IsSecondaryButtonEnabled = false,
+ CloseButtonText = Resources.Action_Cancel,
+ DefaultButton = ContentDialogButton.Close,
+ Content =
+ $"The following files:\n{string.Join('\n', matchingModels.Select(x => $"- {x.FileName}"))}\n"
+ + "and all associated metadata files will be deleted. Are you sure?",
+ };
+
+ var result = await dialog.ShowAsync();
+ if (result == ContentDialogResult.Primary)
+ {
+ foreach (var localModel in matchingModels)
+ {
+ var checkpointPath = new FilePath(localModel.GetFullPath(settingsManager.ModelsDirectory));
+ if (File.Exists(checkpointPath))
+ {
+ File.Delete(checkpointPath);
+ }
+
+ var previewPath = localModel.GetPreviewImageFullPath(settingsManager.ModelsDirectory);
+ if (File.Exists(previewPath))
+ {
+ File.Delete(previewPath);
+ }
+
+ var cmInfoPath = checkpointPath.ToString().Replace(checkpointPath.Extension, ".cm-info.json");
+ if (File.Exists(cmInfoPath))
+ {
+ File.Delete(cmInfoPath);
+ }
+
+ await modelIndexService.RemoveModelAsync(localModel);
+ }
+
+ settingsManager.Transaction(settings => settings.InstalledModelHashes?.Remove(hash));
+ fileToDelete.IsInstalled = false;
+ originalSelectedVersionVm?.RefreshInstallStatus();
+ }
+ }
+
public void PreviousImage()
{
if (SelectedImageIndex > 0)
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
index 5cab55c2..8a5dae66 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
+++ b/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...";
@@ -189,7 +183,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
App.Shutdown();
}
-
+
internal async Task GetReleaseNotes(string changelogUrl)
{
using var client = httpClientFactory.CreateClient();
@@ -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)
diff --git a/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs
new file mode 100644
index 00000000..7db83434
--- /dev/null
+++ b/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; }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs
index 72c47db1..46815f62 100644
--- a/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs
+++ b/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 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;
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs
new file mode 100644
index 00000000..b64a4801
--- /dev/null
+++ b/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 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.GenerateNewSeed();
+
+ ModelCardViewModel = vmFactory.Get();
+
+ SamplerCardViewModel = vmFactory.Get(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();
+
+ SelectImageCardViewModel = vmFactory.Get();
+ SvdImgToVidConditioningViewModel = vmFactory.Get();
+ VideoOutputSettingsCardViewModel = vmFactory.Get();
+
+ StackCardViewModel = vmFactory.Get();
+ StackCardViewModel.AddCards(
+ ModelCardViewModel,
+ SvdImgToVidConditioningViewModel,
+ SamplerCardViewModel,
+ SeedCardViewModel,
+ VideoOutputSettingsCardViewModel,
+ BatchSizeCardViewModel
+ );
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ protected override IEnumerable GetInputImages()
+ {
+ if (SelectImageCardViewModel.ImageSource is { } image)
+ {
+ yield return image;
+ }
+ }
+
+ ///
+ protected override async Task GenerateImageImpl(
+ GenerateOverrides overrides,
+ CancellationToken cancellationToken
+ )
+ {
+ if (!await CheckClientConnectedWithPrompt() || !ClientManager.IsConnected)
+ {
+ return;
+ }
+
+ // If enabled, randomize the seed
+ var seedCard = StackCardViewModel.GetCard();
+ if (overrides is not { UseCurrentSeed: true } && seedCard.IsRandomizeEnabled)
+ {
+ seedCard.GenerateNewSeed();
+ }
+
+ var batches = BatchSizeCardViewModel.BatchCount;
+
+ var batchArgs = new List();
+
+ 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);
+ }
+ }
+
+ ///
+ public void LoadStateFromParameters(GenerationParameters parameters)
+ {
+ SamplerCardViewModel.LoadStateFromParameters(parameters);
+ ModelCardViewModel.LoadStateFromParameters(parameters);
+ SvdImgToVidConditioningViewModel.LoadStateFromParameters(parameters);
+ VideoOutputSettingsCardViewModel.LoadStateFromParameters(parameters);
+
+ SeedCardViewModel.Seed = Convert.ToInt64(parameters.Seed);
+ }
+
+ ///
+ 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);
+ }
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs
index 4531d1b0..2ca1dbd8 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs
+++ b/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
}
///
- 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)
};
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs
index 5336a3e1..1fed6926 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs
+++ b/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;
///
- 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;
+ }
+ }
}
///
@@ -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;
}
///
@@ -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; }
}
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs
index 9a6ad222..5f8c6c51 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs
+++ b/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,
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs
index 4ac393b6..12093310 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs
+++ b/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();
- // 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,
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs
index 4fd98fea..588cd263 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs
+++ b/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
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs
index bad20dba..b6973534 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs
+++ b/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:
///
- ///
- ///
+ /// - - Model, Clip
///
/// Provides:
///
- ///
- ///
+ /// - - Conditioning
///
///
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
///
public GenerationParameters SaveStateToParameters(GenerationParameters parameters)
{
- return parameters with
- {
- PositivePrompt = PromptDocument.Text,
- NegativePrompt = NegativePromptDocument.Text
- };
+ return parameters with { PositivePrompt = PromptDocument.Text, NegativePrompt = NegativePromptDocument.Text };
}
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs
index a0d300a1..96b9de36 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs
+++ b/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,
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs
new file mode 100644
index 00000000..2bbf8021
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs
new file mode 100644
index 00000000..1bb70b26
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs
new file mode 100644
index 00000000..d44f469a
--- /dev/null
+++ b/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 availableMethods = Enum.GetValues().ToList();
+
+ public void LoadStateFromParameters(GenerationParameters parameters)
+ {
+ Fps = parameters.OutputFps;
+ Lossless = parameters.Lossless;
+ Quality = parameters.VideoQuality;
+
+ if (string.IsNullOrWhiteSpace(parameters.VideoOutputMethod))
+ return;
+
+ SelectedMethod = Enum.TryParse(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);
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs
index e3966c1f..3662f452 100644
--- a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs
+++ b/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(package, nameof(package.StartupComplete))
- .Take(1)
- .Subscribe(_ =>
+ IsWaitingForConnection = true;
+ onStartupComplete = Observable
+ .FromEventPattern(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());
+ }
+
///
/// Update the database with current tabs
///
@@ -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"
+ );
}
///
@@ -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();
+
+ 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();
+
+ 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();
+ }
+
///
/// Menu "Open Project" command.
///
diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
index 842df99e..c546d1ee 100644
--- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
+++ b/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();
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();
diff --git a/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs
new file mode 100644
index 00000000..0cd0e689
--- /dev/null
+++ b/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 SubPages { get; }
+
+ [ObservableProperty]
+ private ObservableCollection currentPagePath = [];
+
+ [ObservableProperty]
+ private PageViewModelBase? currentPage;
+
+ public NewPackageManagerViewModel(ServiceManager vmFactory)
+ {
+ SubPages = new PageViewModelBase[]
+ {
+ vmFactory.Get(),
+ vmFactory.Get(),
+ };
+
+ 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 });
+ }
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
index b49ff6d1..f1186751 100644
--- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
+++ b/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();
+ EventManager.Instance.OnInferenceImageToImageRequested(vm.ImageFile);
+ }
+
+ public void SendToImageToVideo(OutputImageViewModel vm)
+ {
+ navigationService.NavigateTo();
+ 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();
diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
index e6d1c6a8..9ce70775 100644
--- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
+++ b/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 { 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)
{
diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs
new file mode 100644
index 00000000..e38770ac
--- /dev/null
+++ b/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 packageNavigationService;
+ private readonly ISettingsManager settingsManager;
+ private readonly INotificationService notificationService;
+ private readonly ILogger logger;
+ private readonly IPyRunner pyRunner;
+ private readonly IPrerequisiteHelper prerequisiteHelper;
+
+ [ObservableProperty]
+ private bool showIncompatiblePackages;
+
+ [ObservableProperty]
+ private string searchFilter = string.Empty;
+
+ private SourceCache packageSource = new(p => p.GithubUrl);
+
+ public IObservableCollection InferencePackages { get; } =
+ new ObservableCollectionExtended();
+
+ public IObservableCollection TrainingPackages { get; } =
+ new ObservableCollectionExtended();
+
+ public PackageInstallBrowserViewModel(
+ IPackageFactory packageFactory,
+ INavigationService packageNavigationService,
+ ISettingsManager settingsManager,
+ INotificationService notificationService,
+ ILogger 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(p => p.IsCompatible || ShowIncompatiblePackages))
+ .AsObservable();
+
+ var searchPredicate = this.WhenPropertyChanged(vm => vm.SearchFilter)
+ .Select(
+ _ =>
+ new Func(
+ p => p.DisplayName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)
+ )
+ )
+ .AsObservable();
+
+ packageSource
+ .Connect()
+ .DeferUntilLoaded()
+ .Filter(incompatiblePredicate)
+ .Filter(searchPredicate)
+ .Where(p => p is { PackageType: PackageType.SdInference })
+ .Sort(
+ SortExpressionComparer
+ .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
+ .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;
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs
new file mode 100644
index 00000000..3c940d25
--- /dev/null
+++ b/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 logger,
+ IPyRunner pyRunner,
+ IPrerequisiteHelper prerequisiteHelper,
+ INavigationService 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 availableVersions = new List();
+
+ [ObservableProperty]
+ private PackageVersion? selectedVersion;
+
+ [ObservableProperty]
+ private SharedFolderMethod selectedSharedFolderMethod;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowTorchVersionOptions))]
+ private TorchVersion selectedTorchVersion;
+
+ [ObservableProperty]
+ private ObservableCollection? 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
+ {
+ 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(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();
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
index 3e834be6..3fc0b95e 100644
--- a/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
+++ b/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;
///
[View(typeof(PackageManagerPage))]
-[Singleton]
+[Singleton, ManagedService]
public partial class PackageManagerViewModel : PageViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly ServiceManager dialogFactory;
private readonly INotificationService notificationService;
+ private readonly INavigationService packageNavigationService;
private readonly ILogger 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 };
///
/// List of installed packages
@@ -71,12 +73,14 @@ public partial class PackageManagerViewModel : PageViewModelBase
ISettingsManager settingsManager,
ServiceManager dialogFactory,
INotificationService notificationService,
+ INavigationService packageNavigationService,
ILogger 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();
- 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();
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
index f88d17f7..6f3c3c4c 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
+++ b/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 { new("CSV") { Patterns = ["*.csv"] } }
+ FileTypeFilter = new List { new("CSV") { Patterns = ["*.csv"] } }
}
);
diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
index 2b79de91..cfc781db 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
+++ b/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 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>> 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(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()
diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
index a044be5d..021dc27f 100644
--- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
+++ b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
@@ -61,15 +61,19 @@
-
-
+
+
+
True
+
+
diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs
index 086780a3..e95b45f4 100644
--- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs
+++ b/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;
}
}
diff --git a/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs b/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs
index e149bf5f..281bfbea 100644
--- a/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs
+++ b/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs
@@ -3,7 +3,6 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
-using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Core.Attributes;
using CivitAiBrowserViewModel = StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser.CivitAiBrowserViewModel;
diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml
index 39152677..d39924d2 100644
--- a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml
+++ b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml
@@ -10,6 +10,9 @@
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
+ xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
+ xmlns:gif="clr-namespace:Avalonia.Gif;assembly=Avalonia.Gif"
+ xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
d:DataContext="{x:Static mocks:DesignData.ImageViewerViewModel}"
@@ -46,24 +49,46 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RowDefinitions="*,Auto">
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -21,6 +23,11 @@
MinWidth="700"
RowDefinitions="Auto, *, Auto"
ColumnDefinitions="*,Auto,*">
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/Inference/InferenceImageToVideoView.axaml b/StabilityMatrix.Avalonia/Views/Inference/InferenceImageToVideoView.axaml
new file mode 100644
index 00000000..0dcb6e0b
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/Inference/InferenceImageToVideoView.axaml
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/Inference/InferenceImageToVideoView.axaml.cs b/StabilityMatrix.Avalonia/Views/Inference/InferenceImageToVideoView.axaml.cs
new file mode 100644
index 00000000..585ae911
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/Inference/InferenceImageToVideoView.axaml.cs
@@ -0,0 +1,13 @@
+using StabilityMatrix.Avalonia.Controls.Dock;
+using StabilityMatrix.Core.Attributes;
+
+namespace StabilityMatrix.Avalonia.Views.Inference;
+
+[Transient]
+public partial class InferenceImageToVideoView : DockUserControlBase
+{
+ public InferenceImageToVideoView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/StabilityMatrix.Avalonia/Views/InferencePage.axaml b/StabilityMatrix.Avalonia/Views/InferencePage.axaml
index a33b41b9..c1a8a74f 100644
--- a/StabilityMatrix.Avalonia/Views/InferencePage.axaml
+++ b/StabilityMatrix.Avalonia/Views/InferencePage.axaml
@@ -70,6 +70,16 @@
Symbol="ResizeImage"/>
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs b/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs
index 967d3ec9..b9921ca2 100644
--- a/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs
+++ b/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs
@@ -55,4 +55,9 @@ public partial class InferencePage : UserControlBase
(DataContext as InferenceViewModel)!.AddTabCommand.Execute(type);
}
}
+
+ private void AddTabMenu_ImgToVideo_OnClick(object? sender, RoutedEventArgs e)
+ {
+ (DataContext as InferenceViewModel)!.AddTabCommand.Execute(InferenceProjectType.ImageToVideo);
+ }
}
diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml b/StabilityMatrix.Avalonia/Views/MainWindow.axaml
index ffd4bb7c..c1f574be 100644
--- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml
+++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml
@@ -16,6 +16,7 @@
Icon="/Assets/Icon.ico"
Width="1100"
Height="750"
+ BackRequested="TopLevel_OnBackRequested"
Title="Stability Matrix"
DockProperties.IsDragEnabled="True"
DockProperties.IsDropEnabled="True"
@@ -73,6 +74,7 @@
IsPaneOpen="False"
OpenPaneLength="200"
IsSettingsVisible="False"
+ IsBackEnabled="False"
MenuItemsSource="{Binding Pages, Mode=OneWay}"
FooterMenuItemsSource="{Binding FooterPages, Mode=OneWay}"
SelectedItem="{Binding SelectedCategory}">
diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs
index 8ef4c918..f512b502 100644
--- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs
+++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs
@@ -4,6 +4,8 @@ using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
+using System.Reactive.Linq;
+using System.Threading;
using AsyncImageLoader;
using Avalonia;
using Avalonia.Controls;
@@ -34,8 +36,11 @@ using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Processes;
+using StabilityMatrix.Core.Services;
+using TeachingTip = FluentAvalonia.UI.Controls.TeachingTip;
#if DEBUG
using StabilityMatrix.Avalonia.Diagnostics.Views;
#endif
@@ -48,6 +53,7 @@ public partial class MainWindow : AppWindowBase
{
private readonly INotificationService notificationService;
private readonly INavigationService navigationService;
+ private readonly ISettingsManager settingsManager;
private FlyoutBase? progressFlyout;
@@ -61,11 +67,13 @@ public partial class MainWindow : AppWindowBase
public MainWindow(
INotificationService notificationService,
- INavigationService navigationService
+ INavigationService navigationService,
+ ISettingsManager settingsManager
)
{
this.notificationService = notificationService;
this.navigationService = navigationService;
+ this.settingsManager = settingsManager;
InitializeComponent();
@@ -82,6 +90,47 @@ public partial class MainWindow : AppWindowBase
EventManager.Instance.ToggleProgressFlyout += (_, _) => progressFlyout?.Hide();
EventManager.Instance.CultureChanged += (_, _) => SetDefaultFonts();
EventManager.Instance.UpdateAvailable += OnUpdateAvailable;
+
+ Observable
+ .FromEventPattern(this, nameof(SizeChanged))
+ .Where(x => x.EventArgs.PreviousSize != x.EventArgs.NewSize)
+ .Throttle(TimeSpan.FromMilliseconds(100))
+ .Select(x => x.EventArgs.NewSize)
+ .ObserveOn(SynchronizationContext.Current)
+ .Subscribe(newSize =>
+ {
+ var validWindowPosition = Screens.All.Any(screen => screen.Bounds.Contains(Position));
+
+ settingsManager.Transaction(
+ s =>
+ {
+ s.WindowSettings = new WindowSettings(
+ newSize.Width,
+ newSize.Height,
+ validWindowPosition ? Position.X : 0,
+ validWindowPosition ? Position.Y : 0
+ );
+ },
+ ignoreMissingLibraryDir: true
+ );
+ });
+
+ Observable
+ .FromEventPattern(this, nameof(PositionChanged))
+ .Where(x => Screens.All.Any(screen => screen.Bounds.Contains(x.EventArgs.Point)))
+ .Throttle(TimeSpan.FromMilliseconds(100))
+ .Select(x => x.EventArgs.Point)
+ .ObserveOn(SynchronizationContext.Current)
+ .Subscribe(position =>
+ {
+ settingsManager.Transaction(
+ s =>
+ {
+ s.WindowSettings = new WindowSettings(Width, Height, position.X, position.Y);
+ },
+ ignoreMissingLibraryDir: true
+ );
+ });
}
///
@@ -116,6 +165,14 @@ public partial class MainWindow : AppWindowBase
base.OnClosing(e);
}
+ ///
+ protected override void OnClosed(EventArgs e)
+ {
+ base.OnClosed(e);
+
+ App.Shutdown();
+ }
+
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -358,4 +415,10 @@ public partial class MainWindow : AppWindowBase
{
ProcessRunner.OpenUrl(Assets.PatreonUrl);
}
+
+ private void TopLevel_OnBackRequested(object? sender, RoutedEventArgs e)
+ {
+ e.Handled = true;
+ navigationService.GoBack();
+ }
}
diff --git a/StabilityMatrix.Avalonia/Views/NewPackageManagerPage.axaml b/StabilityMatrix.Avalonia/Views/NewPackageManagerPage.axaml
new file mode 100644
index 00000000..f2f8f007
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/NewPackageManagerPage.axaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+ 24
+ 17
+ 6,3
+ Medium
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/NewPackageManagerPage.axaml.cs b/StabilityMatrix.Avalonia/Views/NewPackageManagerPage.axaml.cs
new file mode 100644
index 00000000..6fdeb5d4
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/NewPackageManagerPage.axaml.cs
@@ -0,0 +1,127 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Media.Animation;
+using FluentAvalonia.UI.Navigation;
+using Microsoft.Extensions.DependencyInjection;
+using StabilityMatrix.Avalonia.Animations;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.PackageManager;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Models;
+
+namespace StabilityMatrix.Avalonia.Views;
+
+[Singleton]
+public partial class NewPackageManagerPage : UserControlBase, IHandleNavigation
+{
+ private readonly INavigationService packageNavigationService;
+
+ private bool hasLoaded;
+
+ private NewPackageManagerViewModel ViewModel => (NewPackageManagerViewModel)DataContext!;
+
+ [DesignOnly(true)]
+ [Obsolete("For XAML use only", true)]
+ public NewPackageManagerPage()
+ : this(App.Services.GetRequiredService>()) { }
+
+ public NewPackageManagerPage(INavigationService packageNavigationService)
+ {
+ this.packageNavigationService = packageNavigationService;
+
+ InitializeComponent();
+
+ AddHandler(Frame.NavigatedToEvent, OnNavigatedTo, RoutingStrategies.Direct);
+
+ packageNavigationService.SetFrame(FrameView);
+ packageNavigationService.TypedNavigation += NavigationService_OnTypedNavigation;
+ FrameView.Navigated += FrameView_Navigated;
+ BreadcrumbBar.ItemClicked += BreadcrumbBar_ItemClicked;
+ }
+
+ ///
+ protected override void OnLoaded(RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+
+ if (!hasLoaded)
+ {
+ // Initial load, navigate to first page
+ Dispatcher.UIThread.Post(
+ () =>
+ packageNavigationService.NavigateTo(
+ ViewModel.SubPages[0],
+ new SuppressNavigationTransitionInfo()
+ )
+ );
+
+ hasLoaded = true;
+ }
+ }
+
+ ///
+ /// Handle navigation events to this page
+ ///
+ private void OnNavigatedTo(object? sender, NavigationEventArgs args)
+ {
+ if (args.Parameter is PackageManagerNavigationOptions { OpenInstallerDialog: true } options)
+ {
+ var vm = (NewPackageManagerViewModel)DataContext!;
+
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ // Navigate to the installer page
+ packageNavigationService.NavigateTo();
+
+ // Select the package
+ vm.SubPages.OfType()
+ .First()
+ .OnPackageSelected(options.InstallerSelectedPackage);
+ },
+ DispatcherPriority.Send
+ );
+ }
+ }
+
+ private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e)
+ {
+ ViewModel.CurrentPage =
+ ViewModel.SubPages.FirstOrDefault(x => x.GetType() == e.ViewModelType)
+ ?? e.ViewModel as PageViewModelBase;
+ }
+
+ private void FrameView_Navigated(object? sender, NavigationEventArgs args)
+ {
+ if (args.Content is not PageViewModelBase vm)
+ {
+ return;
+ }
+
+ ViewModel.CurrentPage = vm;
+ }
+
+ private void BreadcrumbBar_ItemClicked(BreadcrumbBar sender, BreadcrumbBarItemClickedEventArgs args)
+ {
+ // Skip if already on same page
+ if (args.Item is not PageViewModelBase viewModel || viewModel == ViewModel.CurrentPage)
+ {
+ return;
+ }
+
+ packageNavigationService.NavigateTo(viewModel, BetterSlideNavigationTransition.PageSlideFromLeft);
+ }
+
+ public bool GoBack()
+ {
+ return packageNavigationService.GoBack();
+ }
+}
diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml
index b9f29cfc..22fdbc37 100644
--- a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml
+++ b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml
@@ -219,19 +219,11 @@
IconSource="FullScreenMaximize"
Text="{x:Static lang:Resources.Label_TextToImage}" />
-
+
diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml
new file mode 100644
index 00000000..dc8ba2ca
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml
@@ -0,0 +1,296 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs
new file mode 100644
index 00000000..5abbe50b
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia.Input;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.ViewModels.PackageManager;
+using StabilityMatrix.Core.Attributes;
+
+namespace StabilityMatrix.Avalonia.Views.PackageManager;
+
+[Singleton]
+public partial class PackageInstallBrowserView : UserControlBase
+{
+ public PackageInstallBrowserView()
+ {
+ InitializeComponent();
+ }
+
+ private void InputElement_OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape && DataContext is PackageInstallBrowserViewModel vm)
+ {
+ vm.ClearSearchQuery();
+ }
+ }
+}
diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml
new file mode 100644
index 00000000..88597fc8
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml.cs
new file mode 100644
index 00000000..606bef95
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml.cs
@@ -0,0 +1,13 @@
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Core.Attributes;
+
+namespace StabilityMatrix.Avalonia.Views.PackageManager;
+
+[Transient]
+public partial class PackageInstallDetailView : UserControlBase
+{
+ public PackageInstallDetailView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml
index 25091343..2aeda7b9 100644
--- a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml
+++ b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml
@@ -17,12 +17,6 @@
x:Class="StabilityMatrix.Avalonia.Views.PackageManagerPage">
-
-
-
-
diff --git a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs
index 5282b885..cca1d38a 100644
--- a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs
+++ b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs
@@ -4,6 +4,7 @@ using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Navigation;
using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Packages;
@@ -33,17 +34,10 @@ public partial class PackageManagerPage : UserControlBase
if (args.Parameter is PackageManagerNavigationOptions { OpenInstallerDialog: true } options)
{
var vm = (PackageManagerViewModel)DataContext!;
- Dispatcher.UIThread.InvokeAsync(async () =>
+ Dispatcher.UIThread.Invoke(() =>
{
- await vm.ShowInstallDialog(options.InstallerSelectedPackage);
+ vm.ShowInstallDialog(options.InstallerSelectedPackage);
});
}
}
-
- public record PackageManagerNavigationOptions
- {
- public bool OpenInstallerDialog { get; init; }
-
- public BasePackage? InstallerSelectedPackage { get; init; }
- }
}
diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
index ccf9c439..e5d70f44 100644
--- a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
+++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
@@ -20,6 +20,8 @@
xmlns:update="clr-namespace:StabilityMatrix.Core.Models.Update;assembly=StabilityMatrix.Core"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
+ xmlns:input="clr-namespace:System.Windows.Input;assembly=System.ObjectModel"
+ xmlns:ct="clr-namespace:CommunityToolkit.Mvvm.Input;assembly=CommunityToolkit.Mvvm"
d:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}"
d:DesignHeight="700"
d:DesignWidth="800"
@@ -378,10 +380,12 @@
@@ -566,6 +570,22 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs
index f2737b3d..8df42b6e 100644
--- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs
+++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs
@@ -1,7 +1,6 @@
using System;
using System.ComponentModel;
using System.Linq;
-using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
@@ -15,11 +14,12 @@ using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.Views;
[Singleton]
-public partial class SettingsPage : UserControlBase
+public partial class SettingsPage : UserControlBase, IHandleNavigation
{
private readonly INavigationService settingsNavigationService;
@@ -66,9 +66,7 @@ public partial class SettingsPage : UserControlBase
private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e)
{
- ViewModel.CurrentPage = ViewModel.SubPages.FirstOrDefault(
- x => x.GetType() == e.ViewModelType
- );
+ ViewModel.CurrentPage = ViewModel.SubPages.FirstOrDefault(x => x.GetType() == e.ViewModelType);
}
private async void FrameView_Navigated(object? sender, NavigationEventArgs args)
@@ -81,10 +79,7 @@ public partial class SettingsPage : UserControlBase
ViewModel.CurrentPage = vm;
}
- private async void BreadcrumbBar_ItemClicked(
- BreadcrumbBar sender,
- BreadcrumbBarItemClickedEventArgs args
- )
+ private async void BreadcrumbBar_ItemClicked(BreadcrumbBar sender, BreadcrumbBarItemClickedEventArgs args)
{
// Skip if already on same page
if (args.Item is not PageViewModelBase viewModel || viewModel == ViewModel.CurrentPage)
@@ -92,9 +87,11 @@ public partial class SettingsPage : UserControlBase
return;
}
- settingsNavigationService.NavigateTo(
- viewModel,
- BetterSlideNavigationTransition.PageSlideFromLeft
- );
+ settingsNavigationService.NavigateTo(viewModel, BetterSlideNavigationTransition.PageSlideFromLeft);
+ }
+
+ public bool GoBack()
+ {
+ return settingsNavigationService.GoBack();
}
}
diff --git a/StabilityMatrix.Core/Animation/GifConverter.cs b/StabilityMatrix.Core/Animation/GifConverter.cs
new file mode 100644
index 00000000..ecf1267b
--- /dev/null
+++ b/StabilityMatrix.Core/Animation/GifConverter.cs
@@ -0,0 +1,50 @@
+using KGySoft.Drawing.Imaging;
+using KGySoft.Drawing.SkiaSharp;
+using SkiaSharp;
+
+namespace StabilityMatrix.Core.Animation;
+
+public class GifConverter
+{
+ public static IEnumerable EnumerateAnimatedWebp(Stream webpSource)
+ {
+ using var webp = new SKManagedStream(webpSource);
+ using var codec = SKCodec.Create(webp);
+
+ var info = new SKImageInfo(codec.Info.Width, codec.Info.Height);
+
+ for (var i = 0; i < codec.FrameCount; i++)
+ {
+ using var tempSurface = new SKBitmap(info);
+
+ codec.GetFrameInfo(i, out var frameInfo);
+
+ var decodeInfo = info.WithAlphaType(frameInfo.AlphaType);
+
+ tempSurface.TryAllocPixels(decodeInfo);
+
+ var result = codec.GetPixels(decodeInfo, tempSurface.GetPixels(), new SKCodecOptions(i));
+
+ if (result != SKCodecResult.Success)
+ throw new InvalidDataException($"Could not decode frame {i} of {codec.FrameCount}.");
+
+ using var peekPixels = tempSurface.PeekPixels();
+
+ yield return peekPixels.GetReadableBitmapData(WorkingColorSpace.Default);
+ }
+ }
+
+ public static Task ConvertAnimatedWebpToGifAsync(Stream webpSource, Stream gifOutput)
+ {
+ var gifBitmaps = EnumerateAnimatedWebp(webpSource);
+
+ return GifEncoder.EncodeAnimationAsync(
+ new AnimatedGifConfiguration(gifBitmaps, TimeSpan.FromMilliseconds(150))
+ {
+ Quantizer = OptimizedPaletteQuantizer.Wu(alphaThreshold: 0),
+ AllowDeltaFrames = true
+ },
+ gifOutput
+ );
+ }
+}
diff --git a/StabilityMatrix.Core/Extensions/NullableExtensions.cs b/StabilityMatrix.Core/Extensions/NullableExtensions.cs
new file mode 100644
index 00000000..64264ef6
--- /dev/null
+++ b/StabilityMatrix.Core/Extensions/NullableExtensions.cs
@@ -0,0 +1,47 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+
+namespace StabilityMatrix.Core.Extensions;
+
+public static class NullableExtensions
+{
+ ///
+ /// Unwraps a nullable object, throwing an exception if it is null.
+ ///
+ ///
+ /// Thrown if () is null.
+ ///
+ [DebuggerStepThrough]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static T Unwrap([NotNull] this T? obj, [CallerArgumentExpression("obj")] string? paramName = null)
+ where T : class
+ {
+ if (obj is null)
+ {
+ throw new ArgumentNullException(paramName, $"Unwrap of a null value ({typeof(T)}) {paramName}.");
+ }
+ return obj;
+ }
+
+ ///
+ /// Unwraps a nullable struct object, throwing an exception if it is null.
+ ///
+ ///
+ /// Thrown if () is null.
+ ///
+ [DebuggerStepThrough]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static T Unwrap([NotNull] this T? obj, [CallerArgumentExpression("obj")] string? paramName = null)
+ where T : struct
+ {
+ if (obj is null)
+ {
+ throw new ArgumentNullException(paramName, $"Unwrap of a null value ({typeof(T)}) {paramName}.");
+ }
+ return obj.Value;
+ }
+}
diff --git a/StabilityMatrix.Core/Helper/ArchiveHelper.cs b/StabilityMatrix.Core/Helper/ArchiveHelper.cs
index 04042276..c61893bd 100644
--- a/StabilityMatrix.Core/Helper/ArchiveHelper.cs
+++ b/StabilityMatrix.Core/Helper/ArchiveHelper.cs
@@ -1,10 +1,12 @@
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using NLog;
using SharpCompress.Common;
using SharpCompress.Readers;
using StabilityMatrix.Core.Extensions;
+using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using Timer = System.Timers.Timer;
@@ -85,12 +87,9 @@ public static partial class ArchiveHelper
public static async Task Extract7Z(string archivePath, string extractDirectory)
{
- var args =
- $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y";
+ var args = $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y";
- var result = await ProcessRunner
- .GetProcessResultAsync(SevenZipPath, args)
- .ConfigureAwait(false);
+ var result = await ProcessRunner.GetProcessResultAsync(SevenZipPath, args).ConfigureAwait(false);
result.EnsureSuccessExitCode();
@@ -142,15 +141,10 @@ public static partial class ArchiveHelper
progress.Report(new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract));
// Need -bsp1 for progress reports
- var args =
- $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y -bsp1";
+ var args = $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y -bsp1";
Logger.Debug($"Starting process '{SevenZipPath}' with arguments '{args}'");
- using var process = ProcessRunner.StartProcess(
- SevenZipPath,
- args,
- outputDataReceived: onOutput
- );
+ using var process = ProcessRunner.StartProcess(SevenZipPath, args, outputDataReceived: onOutput);
await ProcessRunner.WaitForExitConditionAsync(process).ConfigureAwait(false);
progress.Report(new ProgressReport(1f, "Finished extracting", type: ProgressType.Extract));
@@ -265,33 +259,28 @@ public static partial class ArchiveHelper
};
}
- await Task.Factory
- .StartNew(
- () =>
- {
- var extractOptions = new ExtractionOptions
- {
- Overwrite = true,
- ExtractFullPath = true,
- };
- using var stream = File.OpenRead(archivePath);
- using var archive = ReaderFactory.Open(stream);
+ await Task.Factory.StartNew(
+ () =>
+ {
+ var extractOptions = new ExtractionOptions { Overwrite = true, ExtractFullPath = true, };
+ using var stream = File.OpenRead(archivePath);
+ using var archive = ReaderFactory.Open(stream);
- // Start the progress reporting timer
- progressMonitor?.Start();
+ // Start the progress reporting timer
+ progressMonitor?.Start();
- while (archive.MoveToNextEntry())
+ while (archive.MoveToNextEntry())
+ {
+ var entry = archive.Entry;
+ if (!entry.IsDirectory)
{
- var entry = archive.Entry;
- if (!entry.IsDirectory)
- {
- count += (ulong)entry.CompressedSize;
- }
- archive.WriteEntryToDirectory(outputDirectory, extractOptions);
+ count += (ulong)entry.CompressedSize;
}
- },
- TaskCreationOptions.LongRunning
- )
+ archive.WriteEntryToDirectory(outputDirectory, extractOptions);
+ }
+ },
+ TaskCreationOptions.LongRunning
+ )
.ConfigureAwait(false);
progress?.Report(new ProgressReport(progress: 1, message: "Done extracting"));
@@ -373,9 +362,7 @@ public static partial class ArchiveHelper
}
catch (IOException e)
{
- Logger.Warn(
- $"Could not extract symbolic link, copying file instead: {e.Message}"
- );
+ Logger.Warn($"Could not extract symbolic link, copying file instead: {e.Message}");
}
}
@@ -386,4 +373,36 @@ public static partial class ArchiveHelper
}
}
}
+
+ [SupportedOSPlatform("macos")]
+ public static async Task ExtractDmg(string archivePath, DirectoryPath extractDir)
+ {
+ using var mountPoint = new TempDirectoryPath();
+
+ // Mount the dmg
+ await ProcessRunner
+ .GetProcessResultAsync("hdiutil", ["attach", archivePath, "-mountpoint", mountPoint])
+ .EnsureSuccessExitCode();
+
+ try
+ {
+ // Copy apps
+ foreach (var sourceDir in mountPoint.EnumerateDirectories("*.app"))
+ {
+ var destDir = extractDir.JoinDir(sourceDir.RelativeTo(mountPoint));
+
+ await ProcessRunner
+ .GetProcessResultAsync("cp", ["-R", sourceDir, destDir])
+ .EnsureSuccessExitCode()
+ .ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ // Unmount the dmg
+ await ProcessRunner
+ .GetProcessResultAsync("hdiutil", ["detach", mountPoint])
+ .ConfigureAwait(false);
+ }
+ }
}
diff --git a/StabilityMatrix.Core/Helper/EventManager.cs b/StabilityMatrix.Core/Helper/EventManager.cs
index 72a2b3c3..22c09cc2 100644
--- a/StabilityMatrix.Core/Helper/EventManager.cs
+++ b/StabilityMatrix.Core/Helper/EventManager.cs
@@ -37,15 +37,14 @@ public class EventManager
public event EventHandler? ImageFileAdded;
public event EventHandler? InferenceTextToImageRequested;
public event EventHandler? InferenceUpscaleRequested;
+ public event EventHandler? InferenceImageToImageRequested;
+ public event EventHandler? InferenceImageToVideoRequested;
- public void OnGlobalProgressChanged(int progress) =>
- GlobalProgressChanged?.Invoke(this, progress);
+ public void OnGlobalProgressChanged(int progress) => GlobalProgressChanged?.Invoke(this, progress);
- public void OnInstalledPackagesChanged() =>
- InstalledPackagesChanged?.Invoke(this, EventArgs.Empty);
+ public void OnInstalledPackagesChanged() => InstalledPackagesChanged?.Invoke(this, EventArgs.Empty);
- public void OnOneClickInstallFinished(bool skipped) =>
- OneClickInstallFinished?.Invoke(this, skipped);
+ public void OnOneClickInstallFinished(bool skipped) => OneClickInstallFinished?.Invoke(this, skipped);
public void OnTeachingTooltipNeeded() => TeachingTooltipNeeded?.Invoke(this, EventArgs.Empty);
@@ -53,11 +52,9 @@ public class EventManager
public void OnUpdateAvailable(UpdateInfo args) => UpdateAvailable?.Invoke(this, args);
- public void OnPackageLaunchRequested(Guid packageId) =>
- PackageLaunchRequested?.Invoke(this, packageId);
+ public void OnPackageLaunchRequested(Guid packageId) => PackageLaunchRequested?.Invoke(this, packageId);
- public void OnScrollToBottomRequested() =>
- ScrollToBottomRequested?.Invoke(this, EventArgs.Empty);
+ public void OnScrollToBottomRequested() => ScrollToBottomRequested?.Invoke(this, EventArgs.Empty);
public void OnProgressChanged(ProgressItem progress) => ProgressChanged?.Invoke(this, progress);
@@ -83,4 +80,10 @@ public class EventManager
public void OnInferenceUpscaleRequested(LocalImageFile imageFile) =>
InferenceUpscaleRequested?.Invoke(this, imageFile);
+
+ public void OnInferenceImageToImageRequested(LocalImageFile imageFile) =>
+ InferenceImageToImageRequested?.Invoke(this, imageFile);
+
+ public void OnInferenceImageToVideoRequested(LocalImageFile imageFile) =>
+ InferenceImageToVideoRequested?.Invoke(this, imageFile);
}
diff --git a/StabilityMatrix.Core/Helper/Factory/IPackageFactory.cs b/StabilityMatrix.Core/Helper/Factory/IPackageFactory.cs
index 266ca3c3..b088c3bf 100644
--- a/StabilityMatrix.Core/Helper/Factory/IPackageFactory.cs
+++ b/StabilityMatrix.Core/Helper/Factory/IPackageFactory.cs
@@ -9,4 +9,5 @@ public interface IPackageFactory
BasePackage? FindPackageByName(string? packageName);
BasePackage? this[string packageName] { get; }
PackagePair? GetPackagePair(InstalledPackage? installedPackage);
+ IEnumerable GetPackagesByType(PackageType packageType);
}
diff --git a/StabilityMatrix.Core/Helper/Factory/PackageFactory.cs b/StabilityMatrix.Core/Helper/Factory/PackageFactory.cs
index 74f049b4..a31bca2b 100644
--- a/StabilityMatrix.Core/Helper/Factory/PackageFactory.cs
+++ b/StabilityMatrix.Core/Helper/Factory/PackageFactory.cs
@@ -39,4 +39,7 @@ public class PackageFactory : IPackageFactory
? null
: new PackagePair(installedPackage, basePackage);
}
+
+ public IEnumerable GetPackagesByType(PackageType packageType) =>
+ basePackages.Values.Where(p => p.PackageType == packageType);
}
diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs b/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs
index f356d59a..eba00cca 100644
--- a/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs
+++ b/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs
@@ -6,6 +6,7 @@ using System.Text.RegularExpressions;
using Hardware.Info;
using Microsoft.Win32;
using NLog;
+using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Core.Helper.HardwareInfo;
@@ -15,7 +16,8 @@ public static partial class HardwareHelper
private static IReadOnlyList? cachedGpuInfos;
- private static readonly Lazy HardwareInfoLazy = new(() => new Hardware.Info.HardwareInfo());
+ private static readonly Lazy HardwareInfoLazy =
+ new(() => new Hardware.Info.HardwareInfo());
public static IHardwareInfo HardwareInfo => HardwareInfoLazy.Value;
@@ -105,6 +107,30 @@ public static partial class HardwareHelper
}
}
+ [SupportedOSPlatform("macos")]
+ private static IEnumerable IterGpuInfoMacos()
+ {
+ HardwareInfo.RefreshVideoControllerList();
+
+ foreach (var (i, videoController) in HardwareInfo.VideoControllerList.Enumerate())
+ {
+ var gpuMemoryBytes = 0ul;
+
+ // For arm macs, use the shared system memory
+ if (Compat.IsArm)
+ {
+ gpuMemoryBytes = GetMemoryInfoImplGeneric().TotalPhysicalBytes;
+ }
+
+ yield return new GpuInfo
+ {
+ Index = i,
+ Name = videoController.Name,
+ MemoryBytes = gpuMemoryBytes
+ };
+ }
+ }
+
///
/// Yields GpuInfo for each GPU in the system.
///
@@ -114,7 +140,8 @@ public static partial class HardwareHelper
{
return IterGpuInfoWindows();
}
- else if (Compat.IsLinux)
+
+ if (Compat.IsLinux)
{
// Since this requires shell commands, fetch cached value if available.
if (cachedGpuInfos is not null)
@@ -126,7 +153,19 @@ public static partial class HardwareHelper
cachedGpuInfos = IterGpuInfoLinux().ToList();
return cachedGpuInfos;
}
- // TODO: Implement for macOS
+
+ if (Compat.IsMacOS)
+ {
+ if (cachedGpuInfos is not null)
+ {
+ return cachedGpuInfos;
+ }
+
+ // No cache, fetch and cache.
+ cachedGpuInfos = IterGpuInfoMacos().ToList();
+ return cachedGpuInfos;
+ }
+
return Enumerable.Empty();
}
@@ -154,6 +193,7 @@ public static partial class HardwareHelper
private static readonly Lazy IsMemoryInfoAvailableLazy = new(() => TryGetMemoryInfo(out _));
public static bool IsMemoryInfoAvailable => IsMemoryInfoAvailableLazy.Value;
+ public static bool IsLiveMemoryUsageInfoAvailable => Compat.IsWindows && IsMemoryInfoAvailable;
public static bool TryGetMemoryInfo(out MemoryInfo memoryInfo)
{
@@ -202,11 +242,22 @@ public static partial class HardwareHelper
private static MemoryInfo GetMemoryInfoImplGeneric()
{
- HardwareInfo.RefreshMemoryList();
+ HardwareInfo.RefreshMemoryStatus();
+
+ // On macos only TotalPhysical is reported
+ if (Compat.IsMacOS)
+ {
+ return new MemoryInfo
+ {
+ TotalPhysicalBytes = HardwareInfo.MemoryStatus.TotalPhysical,
+ TotalInstalledBytes = HardwareInfo.MemoryStatus.TotalPhysical
+ };
+ }
return new MemoryInfo
{
TotalPhysicalBytes = HardwareInfo.MemoryStatus.TotalPhysical,
+ TotalInstalledBytes = HardwareInfo.MemoryStatus.TotalPhysical,
AvailablePhysicalBytes = HardwareInfo.MemoryStatus.AvailablePhysical
};
}
@@ -222,9 +273,10 @@ public static partial class HardwareHelper
{
var info = new CpuInfo();
- using var processorKey = Registry
- .LocalMachine
- .OpenSubKey(@"Hardware\Description\System\CentralProcessor\0", RegistryKeyPermissionCheck.ReadSubTree);
+ using var processorKey = Registry.LocalMachine.OpenSubKey(
+ @"Hardware\Description\System\CentralProcessor\0",
+ RegistryKeyPermissionCheck.ReadSubTree
+ );
if (processorKey?.GetValue("ProcessorNameString") is string processorName)
{
@@ -240,7 +292,20 @@ public static partial class HardwareHelper
{
HardwareInfo.RefreshCPUList();
- return new CpuInfo { ProcessorCaption = HardwareInfo.CpuList.FirstOrDefault()?.Caption.Trim() ?? "" };
+ if (HardwareInfo.CpuList.FirstOrDefault() is not { } cpu)
+ {
+ return default;
+ }
+
+ var processorCaption = cpu.Caption.Trim();
+
+ // Try name if caption is empty (like on macos)
+ if (string.IsNullOrWhiteSpace(processorCaption))
+ {
+ processorCaption = cpu.Name.Trim();
+ }
+
+ return new CpuInfo { ProcessorCaption = processorCaption };
});
}
diff --git a/StabilityMatrix.Core/Helper/ImageMetadata.cs b/StabilityMatrix.Core/Helper/ImageMetadata.cs
index 439242bf..2d3b9f39 100644
--- a/StabilityMatrix.Core/Helper/ImageMetadata.cs
+++ b/StabilityMatrix.Core/Helper/ImageMetadata.cs
@@ -1,7 +1,12 @@
-using System.Text;
+using System.Diagnostics;
+using System.Text;
using System.Text.Json;
+using ExifLibrary;
using MetadataExtractor;
+using MetadataExtractor.Formats.Exif;
using MetadataExtractor.Formats.Png;
+using MetadataExtractor.Formats.WebP;
+using Microsoft.VisualBasic;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
@@ -13,10 +18,13 @@ public class ImageMetadata
{
private IReadOnlyList? Directories { get; set; }
- private static readonly byte[] PngHeader = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+ private static readonly byte[] PngHeader = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private static readonly byte[] Idat = "IDAT"u8.ToArray();
private static readonly byte[] Text = "tEXt"u8.ToArray();
+ private static readonly byte[] Riff = "RIFF"u8.ToArray();
+ private static readonly byte[] Webp = "WEBP"u8.ToArray();
+
public static ImageMetadata ParseFile(FilePath path)
{
return new ImageMetadata { Directories = ImageMetadataReader.ReadMetadata(path) };
@@ -73,6 +81,14 @@ public class ImageMetadata
string? ComfyNodes
) GetAllFileMetadata(FilePath filePath)
{
+ if (filePath.Extension.Equals(".webp", StringComparison.OrdinalIgnoreCase))
+ {
+ var paramsJson = ReadTextChunkFromWebp(filePath, ExifDirectoryBase.TagImageDescription);
+ var smProj = ReadTextChunkFromWebp(filePath, ExifDirectoryBase.TagSoftware);
+
+ return (null, paramsJson, smProj, null);
+ }
+
using var stream = filePath.Info.OpenRead();
using var reader = new BinaryReader(stream);
@@ -227,4 +243,130 @@ public class ImageMetadata
memoryStream.Position = 0;
return memoryStream;
}
+
+ ///
+ /// Reads an EXIF tag from a webp file and returns the value as string
+ ///
+ /// The webp file to read EXIF data from
+ /// Use constants for the tag you'd like to search for
+ ///
+ public static string ReadTextChunkFromWebp(FilePath filePath, int exifTag)
+ {
+ var exifDirs = WebPMetadataReader.ReadMetadata(filePath).OfType().FirstOrDefault();
+ return exifDirs is null ? string.Empty : exifDirs.GetString(exifTag) ?? string.Empty;
+ }
+
+ public static IEnumerable AddMetadataToWebp(
+ byte[] inputImage,
+ Dictionary exifTagData
+ )
+ {
+ using var byteStream = new BinaryReader(new MemoryStream(inputImage));
+ byteStream.BaseStream.Position = 0;
+
+ // Read first 8 bytes and make sure they match the RIFF header
+ if (!byteStream.ReadBytes(4).SequenceEqual(Riff))
+ {
+ return Array.Empty();
+ }
+
+ // skip 4 bytes then read next 4 for webp header
+ byteStream.BaseStream.Position += 4;
+ if (!byteStream.ReadBytes(4).SequenceEqual(Webp))
+ {
+ return Array.Empty();
+ }
+
+ while (byteStream.BaseStream.Position < byteStream.BaseStream.Length - 4)
+ {
+ var chunkType = Encoding.UTF8.GetString(byteStream.ReadBytes(4));
+ var chunkSize = BitConverter.ToInt32(byteStream.ReadBytes(4).ToArray());
+
+ if (chunkType != "EXIF")
+ {
+ // skip chunk data
+ byteStream.BaseStream.Position += chunkSize;
+ continue;
+ }
+
+ var exifStart = byteStream.BaseStream.Position - 8;
+ var exifBytes = byteStream.ReadBytes(chunkSize);
+ Debug.WriteLine($"Found exif chunk of size {chunkSize}");
+
+ using var stream = new MemoryStream(exifBytes[6..]);
+ var img = new MyTiffFile(stream, Encoding.UTF8);
+
+ foreach (var (key, value) in exifTagData)
+ {
+ img.Properties.Set(key, value);
+ }
+
+ using var newStream = new MemoryStream();
+ img.Save(newStream);
+ newStream.Seek(0, SeekOrigin.Begin);
+ var newExifBytes = exifBytes[..6].Concat(newStream.ToArray());
+ var newExifSize = newExifBytes.Count();
+ var newChunkSize = BitConverter.GetBytes(newExifSize);
+ var newChunk = "EXIF"u8.ToArray().Concat(newChunkSize).Concat(newExifBytes).ToArray();
+
+ var inputEndIndex = (int)exifStart;
+ var newImage = inputImage[..inputEndIndex].Concat(newChunk).ToArray();
+
+ // webp or tiff or something requires even number of bytes
+ if (newImage.Length % 2 != 0)
+ {
+ newImage = newImage.Concat(new byte[] { 0x00 }).ToArray();
+ }
+
+ // no clue why the minus 8 is needed but it is
+ var newImageSize = BitConverter.GetBytes(newImage.Length - 8);
+ newImage[4] = newImageSize[0];
+ newImage[5] = newImageSize[1];
+ newImage[6] = newImageSize[2];
+ newImage[7] = newImageSize[3];
+ return newImage;
+ }
+
+ return Array.Empty();
+ }
+
+ private static byte[] GetExifChunks(FilePath imagePath)
+ {
+ using var byteStream = new BinaryReader(File.OpenRead(imagePath));
+ byteStream.BaseStream.Position = 0;
+
+ // Read first 8 bytes and make sure they match the RIFF header
+ if (!byteStream.ReadBytes(4).SequenceEqual(Riff))
+ {
+ return Array.Empty();
+ }
+
+ // skip 4 bytes then read next 4 for webp header
+ byteStream.BaseStream.Position += 4;
+ if (!byteStream.ReadBytes(4).SequenceEqual(Webp))
+ {
+ return Array.Empty();
+ }
+
+ while (byteStream.BaseStream.Position < byteStream.BaseStream.Length - 4)
+ {
+ var chunkType = Encoding.UTF8.GetString(byteStream.ReadBytes(4));
+ var chunkSize = BitConverter.ToInt32(byteStream.ReadBytes(4).ToArray());
+
+ if (chunkType != "EXIF")
+ {
+ // skip chunk data
+ byteStream.BaseStream.Position += chunkSize;
+ continue;
+ }
+
+ var exifStart = byteStream.BaseStream.Position;
+ var exifBytes = byteStream.ReadBytes(chunkSize);
+ var exif = Encoding.UTF8.GetString(exifBytes);
+ Debug.WriteLine($"Found exif chunk of size {chunkSize}");
+ return exifBytes;
+ }
+
+ return Array.Empty();
+ }
}
diff --git a/StabilityMatrix.Core/Helper/MyTiffFile.cs b/StabilityMatrix.Core/Helper/MyTiffFile.cs
new file mode 100644
index 00000000..dd65110b
--- /dev/null
+++ b/StabilityMatrix.Core/Helper/MyTiffFile.cs
@@ -0,0 +1,6 @@
+using System.Text;
+using ExifLibrary;
+
+namespace StabilityMatrix.Core.Helper;
+
+public class MyTiffFile(MemoryStream stream, Encoding encoding) : TIFFFile(stream, encoding);
diff --git a/StabilityMatrix.Core/Helper/SharedFolders.cs b/StabilityMatrix.Core/Helper/SharedFolders.cs
index 33fbc914..7d4d5577 100644
--- a/StabilityMatrix.Core/Helper/SharedFolders.cs
+++ b/StabilityMatrix.Core/Helper/SharedFolders.cs
@@ -35,9 +35,7 @@ public class SharedFolders : ISharedFolders
else
{
// Create parent directory if it doesn't exist, since CreateSymbolicLink doesn't seem to
- new DirectoryPath(junctionDir)
- .Parent
- ?.Create();
+ new DirectoryPath(junctionDir).Parent?.Create();
Directory.CreateSymbolicLink(junctionDir, targetDir);
}
}
@@ -195,7 +193,10 @@ public class SharedFolders : ISharedFolders
var sharedFolderMethod =
package.PreferredSharedFolderMethod ?? basePackage.RecommendedSharedFolderMethod;
- basePackage.RemoveModelFolderLinks(package.FullPath, sharedFolderMethod).GetAwaiter().GetResult();
+ basePackage
+ .RemoveModelFolderLinks(package.FullPath, sharedFolderMethod)
+ .GetAwaiter()
+ .GetResult();
}
catch (Exception e)
{
diff --git a/StabilityMatrix.Core/Helper/Utilities.cs b/StabilityMatrix.Core/Helper/Utilities.cs
index 07afca73..5e8bfc2e 100644
--- a/StabilityMatrix.Core/Helper/Utilities.cs
+++ b/StabilityMatrix.Core/Helper/Utilities.cs
@@ -13,8 +13,12 @@ public static class Utilities
: $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
}
- public static void CopyDirectory(string sourceDir, string destinationDir, bool recursive,
- bool includeReparsePoints = false)
+ public static void CopyDirectory(
+ string sourceDir,
+ string destinationDir,
+ bool recursive,
+ bool includeReparsePoints = false
+ )
{
// Get information about the source directory
var dir = new DirectoryInfo(sourceDir);
@@ -35,11 +39,13 @@ public static class Utilities
foreach (var file in dir.GetFiles())
{
var targetFilePath = Path.Combine(destinationDir, file.Name);
- if (file.FullName == targetFilePath) continue;
+ if (file.FullName == targetFilePath)
+ continue;
file.CopyTo(targetFilePath, true);
}
- if (!recursive) return;
+ if (!recursive)
+ return;
// If recursive and copying subdirectories, recursively call this method
foreach (var subDir in dirs)
@@ -48,4 +54,13 @@ public static class Utilities
CopyDirectory(subDir.FullName, newDestinationDir, true);
}
}
+
+ public static MemoryStream? GetMemoryStreamFromFile(string filePath)
+ {
+ var fileBytes = File.ReadAllBytes(filePath);
+ var stream = new MemoryStream(fileBytes);
+ stream.Position = 0;
+
+ return stream;
+ }
}
diff --git a/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/ConditioningConnections.cs b/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/ConditioningConnections.cs
new file mode 100644
index 00000000..02a3c2af
--- /dev/null
+++ b/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/ConditioningConnections.cs
@@ -0,0 +1,12 @@
+namespace StabilityMatrix.Core.Models.Api.Comfy.NodeTypes;
+
+///
+/// Combination of the positive and negative conditioning connections.
+///
+public record ConditioningConnections(ConditioningNodeConnection Positive, ConditioningNodeConnection Negative)
+{
+ // Implicit from tuple
+ public static implicit operator ConditioningConnections(
+ (ConditioningNodeConnection Positive, ConditioningNodeConnection Negative) value
+ ) => new(value.Positive, value.Negative);
+}
diff --git a/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/ModelConnections.cs b/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/ModelConnections.cs
new file mode 100644
index 00000000..abb66596
--- /dev/null
+++ b/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/ModelConnections.cs
@@ -0,0 +1,15 @@
+namespace StabilityMatrix.Core.Models.Api.Comfy.NodeTypes;
+
+///
+/// Connections from a loaded model
+///
+public record ModelConnections(string Name)
+{
+ public ModelNodeConnection? Model { get; set; }
+
+ public VAENodeConnection? VAE { get; set; }
+
+ public ClipNodeConnection? Clip { get; set; }
+
+ public ConditioningConnections? Conditioning { get; set; }
+}
diff --git a/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/NodeConnections.cs b/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/NodeConnections.cs
index a574d5ec..c540019e 100644
--- a/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/NodeConnections.cs
+++ b/StabilityMatrix.Core/Models/Api/Comfy/NodeTypes/NodeConnections.cs
@@ -18,6 +18,8 @@ public class ClipNodeConnection : NodeConnectionBase { }
public class ControlNetNodeConnection : NodeConnectionBase { }
+public class ClipVisionNodeConnection : NodeConnectionBase { }
+
public class SamplerNodeConnection : NodeConnectionBase { }
public class SigmasNodeConnection : NodeConnectionBase { }
diff --git a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs
index 48fa3a79..874397ef 100644
--- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs
+++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs
@@ -122,6 +122,14 @@ public class ComfyNodeBuilder
public required int Width { get; init; }
}
+ public record CLIPSetLastLayer : ComfyTypedNodeBase
+ {
+ public required ClipNodeConnection Clip { get; init; }
+
+ [Range(-24, -1)]
+ public int StopAtClipLayer { get; init; } = -1;
+ }
+
public static NamedComfyNode LatentFromBatch(
string name,
LatentNodeConnection samples,
@@ -224,6 +232,12 @@ public class ComfyNodeBuilder
public required string CkptName { get; init; }
}
+ public record ImageOnlyCheckpointLoader
+ : ComfyTypedNodeBase
+ {
+ public required string CkptName { get; init; }
+ }
+
public record FreeU : ComfyTypedNodeBase
{
public required ModelNodeConnection Model { get; init; }
@@ -291,6 +305,36 @@ public class ComfyNodeBuilder
public required double EndPercent { get; init; }
}
+ public record SVD_img2vid_Conditioning
+ : ComfyTypedNodeBase
+ {
+ public required ClipVisionNodeConnection ClipVision { get; init; }
+ public required ImageNodeConnection InitImage { get; init; }
+ public required VAENodeConnection Vae { get; init; }
+ public required int Width { get; init; }
+ public required int Height { get; init; }
+ public required int VideoFrames { get; init; }
+ public required int MotionBucketId { get; init; }
+ public required int Fps { get; set; }
+ public required double AugmentationLevel { get; init; }
+ }
+
+ public record VideoLinearCFGGuidance : ComfyTypedNodeBase
+ {
+ public required ModelNodeConnection Model { get; init; }
+ public required double MinCfg { get; init; }
+ }
+
+ public record SaveAnimatedWEBP : ComfyTypedNodeBase
+ {
+ public required ImageNodeConnection Images { get; init; }
+ public required string FilenamePrefix { get; init; }
+ public required double Fps { get; init; }
+ public required bool Lossless { get; init; }
+ public required int Quality { get; init; }
+ public required string Method { get; init; }
+ }
+
public ImageNodeConnection Lambda_LatentToImage(LatentNodeConnection latent, VAENodeConnection vae)
{
var name = GetUniqueName("VAEDecode");
@@ -750,18 +794,19 @@ public class ComfyNodeBuilder
public int BatchSize { get; set; } = 1;
public int? BatchIndex { get; set; }
- public ModelNodeConnection? BaseModel { get; set; }
- public VAENodeConnection? BaseVAE { get; set; }
public ClipNodeConnection? BaseClip { get; set; }
+ public ClipVisionNodeConnection? BaseClipVision { get; set; }
+
+ public Dictionary Models { get; } =
+ new() { ["Base"] = new ModelConnections("Base"), ["Refiner"] = new ModelConnections("Refiner") };
- public ConditioningNodeConnection? BaseConditioning { get; set; }
- public ConditioningNodeConnection? BaseNegativeConditioning { get; set; }
+ ///
+ /// ModelConnections from with set
+ ///
+ public IEnumerable LoadedModels => Models.Values.Where(m => m.Model is not null);
- public ModelNodeConnection? RefinerModel { get; set; }
- public VAENodeConnection? RefinerVAE { get; set; }
- public ClipNodeConnection? RefinerClip { get; set; }
- public ConditioningNodeConnection? RefinerConditioning { get; set; }
- public ConditioningNodeConnection? RefinerNegativeConditioning { get; set; }
+ public ModelConnections Base => Models["Base"];
+ public ModelConnections Refiner => Models["Refiner"];
public PrimaryNodeConnection? Primary { get; set; }
public VAENodeConnection? PrimaryVAE { get; set; }
@@ -776,26 +821,21 @@ public class ComfyNodeBuilder
public ModelNodeConnection GetRefinerOrBaseModel()
{
- return RefinerModel ?? BaseModel ?? throw new NullReferenceException("No Model");
- }
-
- public ConditioningNodeConnection GetRefinerOrBaseConditioning()
- {
- return RefinerConditioning
- ?? BaseConditioning
- ?? throw new NullReferenceException("No Conditioning");
+ return Refiner.Model
+ ?? Base.Model
+ ?? throw new NullReferenceException("No Refiner or Base Model");
}
- public ConditioningNodeConnection GetRefinerOrBaseNegativeConditioning()
+ public ConditioningConnections GetRefinerOrBaseConditioning()
{
- return RefinerNegativeConditioning
- ?? BaseNegativeConditioning
- ?? throw new NullReferenceException("No Negative Conditioning");
+ return Refiner.Conditioning
+ ?? Base.Conditioning
+ ?? throw new NullReferenceException("No Refiner or Base Conditioning");
}
public VAENodeConnection GetDefaultVAE()
{
- return PrimaryVAE ?? RefinerVAE ?? BaseVAE ?? throw new NullReferenceException("No VAE");
+ return PrimaryVAE ?? Refiner.VAE ?? Base.VAE ?? throw new NullReferenceException("No VAE");
}
}
diff --git a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/NodeDictionary.cs b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/NodeDictionary.cs
index 051cf7b2..745abb74 100644
--- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/NodeDictionary.cs
+++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/NodeDictionary.cs
@@ -25,9 +25,7 @@ public class NodeDictionary : Dictionary
// Ensure new name does not exist
if (ContainsKey(nameBase))
{
- throw new InvalidOperationException(
- $"Initial unique name already exists for base {nameBase}"
- );
+ throw new InvalidOperationException($"Initial unique name already exists for base {nameBase}");
}
_baseNameIndex.Add(nameBase, 1);
diff --git a/StabilityMatrix.Core/Models/Database/LocalImageFile.cs b/StabilityMatrix.Core/Models/Database/LocalImageFile.cs
index 1abde8dd..54153635 100644
--- a/StabilityMatrix.Core/Models/Database/LocalImageFile.cs
+++ b/StabilityMatrix.Core/Models/Database/LocalImageFile.cs
@@ -1,4 +1,5 @@
using DynamicData.Tests;
+using MetadataExtractor.Formats.Exif;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using JsonSerializer = System.Text.Json.JsonSerializer;
@@ -48,19 +49,20 @@ public record LocalImageFile
///
public string FileNameWithoutExtension => Path.GetFileNameWithoutExtension(AbsolutePath);
- public (
- string? Parameters,
- string? ParametersJson,
- string? SMProject,
- string? ComfyNodes
- ) ReadMetadata()
+ public (string? Parameters, string? ParametersJson, string? SMProject, string? ComfyNodes) ReadMetadata()
{
- using var stream = new FileStream(
- AbsolutePath,
- FileMode.Open,
- FileAccess.Read,
- FileShare.Read
- );
+ if (AbsolutePath.EndsWith("webp"))
+ {
+ var paramsJson = ImageMetadata.ReadTextChunkFromWebp(
+ AbsolutePath,
+ ExifDirectoryBase.TagImageDescription
+ );
+ var smProj = ImageMetadata.ReadTextChunkFromWebp(AbsolutePath, ExifDirectoryBase.TagSoftware);
+
+ return (null, paramsJson, smProj, null);
+ }
+
+ using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
var parameters = ImageMetadata.ReadTextChunk(reader, "parameters");
@@ -79,8 +81,30 @@ public record LocalImageFile
public static LocalImageFile FromPath(FilePath filePath)
{
// TODO: Support other types
- const LocalImageFileType imageType =
- LocalImageFileType.Inference | LocalImageFileType.TextToImage;
+ const LocalImageFileType imageType = LocalImageFileType.Inference | LocalImageFileType.TextToImage;
+
+ if (filePath.Extension.Equals(".webp", StringComparison.OrdinalIgnoreCase))
+ {
+ var paramsJson = ImageMetadata.ReadTextChunkFromWebp(
+ filePath,
+ ExifDirectoryBase.TagImageDescription
+ );
+ var parameters = string.IsNullOrWhiteSpace(paramsJson)
+ ? null
+ : JsonSerializer.Deserialize(paramsJson);
+
+ filePath.Info.Refresh();
+
+ return new LocalImageFile
+ {
+ AbsolutePath = filePath,
+ ImageType = imageType,
+ CreatedAt = filePath.Info.CreationTimeUtc,
+ LastModifiedAt = filePath.Info.LastWriteTimeUtc,
+ GenerationParameters = parameters,
+ ImageSize = new Size(parameters?.Width ?? 0, parameters?.Height ?? 0)
+ };
+ }
// Get metadata
using var stream = filePath.Info.OpenRead();
@@ -116,5 +140,11 @@ public record LocalImageFile
}
public static readonly HashSet SupportedImageExtensions =
- new() { ".png", ".jpg", ".jpeg", ".webp" };
+ [
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp"
+ ];
}
diff --git a/StabilityMatrix.Core/Models/Database/LocalModelFile.cs b/StabilityMatrix.Core/Models/Database/LocalModelFile.cs
index e5f92cef..01db400b 100644
--- a/StabilityMatrix.Core/Models/Database/LocalModelFile.cs
+++ b/StabilityMatrix.Core/Models/Database/LocalModelFile.cs
@@ -6,13 +6,13 @@ namespace StabilityMatrix.Core.Models.Database;
///
/// Represents a locally indexed model file.
///
-public class LocalModelFile
+public record LocalModelFile
{
///
/// Relative path to the file from the root model directory.
///
[BsonId]
- public required string RelativePath { get; set; }
+ public required string RelativePath { get; init; }
///
/// Type of the model file.
@@ -29,6 +29,11 @@ public class LocalModelFile
///
public string? PreviewImageRelativePath { get; set; }
+ ///
+ /// Optional preview image full path. Takes priority over .
+ ///
+ public string? PreviewImageFullPath { get; set; }
+
///
/// File name of the relative path.
///
@@ -45,7 +50,33 @@ public class LocalModelFile
/// Relative file path from the shared folder type model directory.
///
[BsonIgnore]
- public string RelativePathFromSharedFolder => Path.GetRelativePath(SharedFolderType.GetStringValue(), RelativePath);
+ public string RelativePathFromSharedFolder =>
+ Path.GetRelativePath(SharedFolderType.GetStringValue(), RelativePath);
+
+ ///
+ /// Blake3 hash of the file.
+ ///
+ public string? HashBlake3 => ConnectedModelInfo?.Hashes.BLAKE3;
+
+ [BsonIgnore]
+ public string FullPathGlobal => GetFullPath(GlobalConfig.LibraryDir.JoinDir("Models"));
+
+ [BsonIgnore]
+ public string? PreviewImageFullPathGlobal =>
+ PreviewImageFullPath ?? GetPreviewImageFullPath(GlobalConfig.LibraryDir.JoinDir("Models"));
+
+ [BsonIgnore]
+ public Uri? PreviewImageUriGlobal =>
+ PreviewImageFullPathGlobal == null ? null : new Uri(PreviewImageFullPathGlobal);
+
+ [BsonIgnore]
+ public string DisplayModelName => ConnectedModelInfo?.ModelName ?? FileNameWithoutExtension;
+
+ [BsonIgnore]
+ public string DisplayModelVersion => ConnectedModelInfo?.VersionName ?? string.Empty;
+
+ [BsonIgnore]
+ public string DisplayModelFileName => FileName;
public string GetFullPath(string rootModelDirectory)
{
@@ -54,16 +85,15 @@ public class LocalModelFile
public string? GetPreviewImageFullPath(string rootModelDirectory)
{
- return PreviewImageRelativePath == null ? null : Path.Combine(rootModelDirectory, PreviewImageRelativePath);
- }
+ if (PreviewImageFullPath != null)
+ return PreviewImageFullPath;
- [BsonIgnore]
- public string FullPathGlobal => GetFullPath(GlobalConfig.LibraryDir.JoinDir("Models"));
-
- [BsonIgnore]
- public string? PreviewImageFullPathGlobal => GetPreviewImageFullPath(GlobalConfig.LibraryDir.JoinDir("Models"));
+ return PreviewImageRelativePath == null
+ ? null
+ : Path.Combine(rootModelDirectory, PreviewImageRelativePath);
+ }
- protected bool Equals(LocalModelFile other)
+ /*protected bool Equals(LocalModelFile other)
{
return RelativePath == other.RelativePath;
}
@@ -84,7 +114,7 @@ public class LocalModelFile
public override int GetHashCode()
{
return RelativePath.GetHashCode();
- }
+ }*/
public static readonly HashSet SupportedCheckpointExtensions =
[
@@ -94,6 +124,6 @@ public class LocalModelFile
".pth",
".bin"
];
- public static readonly HashSet SupportedImageExtensions = [".png", ".jpg", ".jpeg", ".gif"];
+ public static readonly HashSet SupportedImageExtensions = [".png", ".jpg", ".jpeg", ".webp"];
public static readonly HashSet SupportedMetadataExtensions = [".json"];
}
diff --git a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
index bd94d5f8..088cf592 100644
--- a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
+++ b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
@@ -57,6 +57,11 @@ public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable
public long GetSize()
{
@@ -81,9 +86,7 @@ public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable file.Length);
var subDirs = Info.GetDirectories()
.Where(dir => !dir.Attributes.HasFlag(FileAttributes.ReparsePoint))
- .Sum(
- dir => dir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length)
- );
+ .Sum(dir => dir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length));
return files + subDirs;
}
diff --git a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
index 494e3aa5..0c637c97 100644
--- a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
+++ b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
@@ -71,6 +71,11 @@ public partial class FilePath : FileSystemPath, IPathObject
public FilePath(params string[] paths)
: base(paths) { }
+ public FilePath RelativeTo(DirectoryPath path)
+ {
+ return new FilePath(Path.GetRelativePath(path.FullPath, FullPath));
+ }
+
public long GetSize()
{
Info.Refresh();
diff --git a/StabilityMatrix.Core/Models/GenerationParameters.cs b/StabilityMatrix.Core/Models/GenerationParameters.cs
index c0b5aa9b..46f7459a 100644
--- a/StabilityMatrix.Core/Models/GenerationParameters.cs
+++ b/StabilityMatrix.Core/Models/GenerationParameters.cs
@@ -20,6 +20,15 @@ public partial record GenerationParameters
public int Width { get; set; }
public string? ModelHash { get; set; }
public string? ModelName { get; set; }
+ public int FrameCount { get; set; }
+ public int MotionBucketId { get; set; }
+ public int VideoQuality { get; set; }
+ public bool Lossless { get; set; }
+ public int Fps { get; set; }
+ public double OutputFps { get; set; }
+ public double MinCfg { get; set; }
+ public double AugmentationLevel { get; set; }
+ public string? VideoOutputMethod { get; set; }
public static bool TryParse(
string? text,
diff --git a/StabilityMatrix.Core/Models/IHandleNavigation.cs b/StabilityMatrix.Core/Models/IHandleNavigation.cs
new file mode 100644
index 00000000..14011508
--- /dev/null
+++ b/StabilityMatrix.Core/Models/IHandleNavigation.cs
@@ -0,0 +1,6 @@
+namespace StabilityMatrix.Core.Models;
+
+public interface IHandleNavigation
+{
+ bool GoBack();
+}
diff --git a/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs b/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs
new file mode 100644
index 00000000..686c09ee
--- /dev/null
+++ b/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs
@@ -0,0 +1,15 @@
+using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Models.Packages.Extensions;
+using StabilityMatrix.Core.Models.Progress;
+
+namespace StabilityMatrix.Core.Models.PackageModification;
+
+public class InstallExtensionStep(ExtensionBase extension, DirectoryPath extensionsDir) : IPackageStep
+{
+ public Task ExecuteAsync(IProgress? progress = null)
+ {
+ return extension.InstallExtensionAsync(extensionsDir, extension.MainBranch);
+ }
+
+ public string ProgressTitle => $"Installing {extension.DisplayName}";
+}
diff --git a/StabilityMatrix.Core/Models/PackageType.cs b/StabilityMatrix.Core/Models/PackageType.cs
new file mode 100644
index 00000000..92a2a847
--- /dev/null
+++ b/StabilityMatrix.Core/Models/PackageType.cs
@@ -0,0 +1,7 @@
+namespace StabilityMatrix.Core.Models;
+
+public enum PackageType
+{
+ SdInference,
+ SdTraining
+}
diff --git a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
index 373c19f4..ebe7856f 100644
--- a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
+++ b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
@@ -2,6 +2,7 @@
using System.IO.Compression;
using NLog;
using Octokit;
+using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Models.Database;
@@ -147,10 +148,24 @@ public abstract class BaseGitPackage : BasePackage
await VenvRunner.DisposeAsync().ConfigureAwait(false);
}
+ // Set additional required environment variables
+ var env = new Dictionary();
+ if (SettingsManager.Settings.EnvironmentVariables is not null)
+ {
+ env.Update(SettingsManager.Settings.EnvironmentVariables);
+ }
+
+ if (Compat.IsWindows)
+ {
+ var tkPath = Path.Combine(SettingsManager.LibraryDir, "Assets", "Python310", "tcl", "tcl8.6");
+ env["TCL_LIBRARY"] = tkPath;
+ env["TK_LIBRARY"] = tkPath;
+ }
+
VenvRunner = new PyVenvRunner(venvPath)
{
WorkingDirectory = installedPackagePath,
- EnvironmentVariables = SettingsManager.Settings.EnvironmentVariables,
+ EnvironmentVariables = env
};
if (!VenvRunner.Exists() || forceRecreate)
diff --git a/StabilityMatrix.Core/Models/Packages/BasePackage.cs b/StabilityMatrix.Core/Models/Packages/BasePackage.cs
index 063693e7..68fd939a 100644
--- a/StabilityMatrix.Core/Models/Packages/BasePackage.cs
+++ b/StabilityMatrix.Core/Models/Packages/BasePackage.cs
@@ -3,6 +3,7 @@ using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.HardwareInfo;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Models.Packages.Extensions;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Python;
@@ -47,6 +48,8 @@ public abstract class BasePackage
public abstract PackageDifficulty InstallerSortOrder { get; }
+ public virtual PackageType PackageType => PackageType.SdInference;
+
public abstract Task DownloadPackage(
string installLocation,
DownloadPackageVersionOptions versionOptions,
@@ -85,11 +88,20 @@ public abstract class BasePackage
public abstract SharedFolderMethod RecommendedSharedFolderMethod { get; }
- public abstract Task SetupModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod);
+ public abstract Task SetupModelFolders(
+ DirectoryPath installDirectory,
+ SharedFolderMethod sharedFolderMethod
+ );
- public abstract Task UpdateModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod);
+ public abstract Task UpdateModelFolders(
+ DirectoryPath installDirectory,
+ SharedFolderMethod sharedFolderMethod
+ );
- public abstract Task RemoveModelFolderLinks(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod);
+ public abstract Task RemoveModelFolderLinks(
+ DirectoryPath installDirectory,
+ SharedFolderMethod sharedFolderMethod
+ );
public abstract Task SetupOutputFolderLinks(DirectoryPath installDirectory);
public abstract Task RemoveOutputFolderLinks(DirectoryPath installDirectory);
@@ -117,6 +129,11 @@ public abstract class BasePackage
return TorchVersion.DirectMl;
}
+ if (Compat.IsMacOS && Compat.IsArm && AvailableTorchVersions.Contains(TorchVersion.Mps))
+ {
+ return TorchVersion.Mps;
+ }
+
return TorchVersion.Cpu;
}
@@ -142,7 +159,11 @@ public abstract class BasePackage
public abstract Dictionary>? SharedOutputFolders { get; }
public abstract Task GetAllVersionOptions();
- public abstract Task?> GetAllCommits(string branch, int page = 1, int perPage = 10);
+ public abstract Task?> GetAllCommits(
+ string branch,
+ int page = 1,
+ int perPage = 10
+ );
public abstract Task GetLatestVersion(bool includePrerelease = false);
public abstract string MainBranch { get; }
public event EventHandler? Exited;
@@ -153,7 +174,9 @@ public abstract class BasePackage
public void OnStartupComplete(string url) => StartupComplete?.Invoke(this, url);
public virtual PackageVersionType AvailableVersionTypes =>
- ShouldIgnoreReleases ? PackageVersionType.Commit : PackageVersionType.GithubRelease | PackageVersionType.Commit;
+ ShouldIgnoreReleases
+ ? PackageVersionType.Commit
+ : PackageVersionType.GithubRelease | PackageVersionType.Commit;
protected async Task InstallCudaTorch(
PyVenvRunner venvRunner,
@@ -194,6 +217,9 @@ public abstract class BasePackage
{
progress?.Report(new ProgressReport(-1f, "Installing PyTorch for CPU", isIndeterminate: true));
- return venvRunner.PipInstall(new PipInstallArgs().WithTorch("==2.0.1").WithTorchVision(), onConsoleOutput);
+ return venvRunner.PipInstall(
+ new PipInstallArgs().WithTorch("==2.0.1").WithTorchVision(),
+ onConsoleOutput
+ );
}
}
diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs
index 81a415c4..5ac92976 100644
--- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs
+++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs
@@ -6,6 +6,7 @@ using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Helper.HardwareInfo;
using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Models.Packages.Extensions;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Python;
@@ -114,10 +115,36 @@ public class ComfyUI(
{
Name = "Use CPU only",
Type = LaunchOptionType.Bool,
- InitialValue = !HardwareHelper.HasNvidiaGpu() && !HardwareHelper.HasAmdGpu(),
+ InitialValue =
+ !Compat.IsMacOS && !HardwareHelper.HasNvidiaGpu() && !HardwareHelper.HasAmdGpu(),
Options = ["--cpu"]
},
new LaunchOptionDefinition
+ {
+ Name = "Cross Attention Method",
+ Type = LaunchOptionType.Bool,
+ InitialValue = Compat.IsMacOS ? "--use-pytorch-cross-attention" : null,
+ Options =
+ [
+ "--use-split-cross-attention",
+ "--use-quad-cross-attention",
+ "--use-pytorch-cross-attention"
+ ]
+ },
+ new LaunchOptionDefinition
+ {
+ Name = "Force Floating Point Precision",
+ Type = LaunchOptionType.Bool,
+ InitialValue = Compat.IsMacOS ? "--force-fp16" : null,
+ Options = ["--force-fp32", "--force-fp16"]
+ },
+ new LaunchOptionDefinition
+ {
+ Name = "VAE Precision",
+ Type = LaunchOptionType.Bool,
+ Options = ["--fp16-vae", "--fp32-vae", "--bf16-vae"]
+ },
+ new LaunchOptionDefinition
{
Name = "Disable Xformers",
Type = LaunchOptionType.Bool,
@@ -142,7 +169,14 @@ public class ComfyUI(
public override string MainBranch => "master";
public override IEnumerable AvailableTorchVersions =>
- new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.DirectMl, TorchVersion.Rocm, TorchVersion.Mps };
+ new[]
+ {
+ TorchVersion.Cpu,
+ TorchVersion.Cuda,
+ TorchVersion.DirectMl,
+ TorchVersion.Rocm,
+ TorchVersion.Mps
+ };
public override async Task InstallPackage(
string installLocation,
@@ -161,7 +195,9 @@ public class ComfyUI(
await venvRunner.PipInstall("--upgrade pip wheel", onConsoleOutput).ConfigureAwait(false);
- progress?.Report(new ProgressReport(-1f, "Installing Package Requirements...", isIndeterminate: true));
+ progress?.Report(
+ new ProgressReport(-1f, "Installing Package Requirements...", isIndeterminate: true)
+ );
var pipArgs = new PipInstallArgs();
@@ -181,7 +217,12 @@ public class ComfyUI(
TorchVersion.Cpu => "cpu",
TorchVersion.Cuda => "cu121",
TorchVersion.Rocm => "rocm5.6",
- _ => throw new ArgumentOutOfRangeException(nameof(torchVersion), torchVersion, null)
+ _
+ => throw new ArgumentOutOfRangeException(
+ nameof(torchVersion),
+ torchVersion,
+ null
+ )
}
)
};
@@ -239,16 +280,23 @@ public class ComfyUI(
}
}
- public override Task SetupModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod) =>
+ public override Task SetupModelFolders(
+ DirectoryPath installDirectory,
+ SharedFolderMethod sharedFolderMethod
+ ) =>
sharedFolderMethod switch
{
- SharedFolderMethod.Symlink => base.SetupModelFolders(installDirectory, SharedFolderMethod.Symlink),
+ SharedFolderMethod.Symlink
+ => base.SetupModelFolders(installDirectory, SharedFolderMethod.Symlink),
SharedFolderMethod.Configuration => SetupModelFoldersConfig(installDirectory),
SharedFolderMethod.None => Task.CompletedTask,
_ => throw new ArgumentOutOfRangeException(nameof(sharedFolderMethod), sharedFolderMethod, null)
};
- public override Task RemoveModelFolderLinks(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod)
+ public override Task RemoveModelFolderLinks(
+ DirectoryPath installDirectory,
+ SharedFolderMethod sharedFolderMethod
+ )
{
return sharedFolderMethod switch
{
@@ -286,7 +334,9 @@ public class ComfyUI(
throw new Exception("Invalid extra_model_paths.yaml");
}
// check if we have a child called "stability_matrix"
- var stabilityMatrixNode = mappingNode.Children.FirstOrDefault(c => c.Key.ToString() == "stability_matrix");
+ var stabilityMatrixNode = mappingNode.Children.FirstOrDefault(
+ c => c.Key.ToString() == "stability_matrix"
+ );
if (stabilityMatrixNode.Key != null)
{
@@ -337,7 +387,11 @@ public class ComfyUI(
{ "hypernetworks", Path.Combine(modelsDir, "Hypernetwork") },
{
"controlnet",
- string.Join('\n', Path.Combine(modelsDir, "ControlNet"), Path.Combine(modelsDir, "T2IAdapter"))
+ string.Join(
+ '\n',
+ Path.Combine(modelsDir, "ControlNet"),
+ Path.Combine(modelsDir, "T2IAdapter")
+ )
},
{ "clip", Path.Combine(modelsDir, "CLIP") },
{ "clip_vision", Path.Combine(modelsDir, "InvokeClipVision") },
@@ -401,7 +455,9 @@ public class ComfyUI(
mappingNode.Children.Remove("stability_matrix");
- var serializer = new SerializerBuilder().WithNamingConvention(UnderscoredNamingConvention.Instance).Build();
+ var serializer = new SerializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .Build();
var yamlData = serializer.Serialize(mappingNode);
await extraPathsYamlPath.WriteAllTextAsync(yamlData).ConfigureAwait(false);
diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionBase.cs b/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionBase.cs
new file mode 100644
index 00000000..240d39a3
--- /dev/null
+++ b/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionBase.cs
@@ -0,0 +1,24 @@
+using StabilityMatrix.Core.Models.FileInterfaces;
+
+namespace StabilityMatrix.Core.Models.Packages.Extensions;
+
+public abstract class ExtensionBase
+{
+ public string ByAuthor => $"By {Author}";
+
+ public abstract string RepoName { get; }
+ public abstract string DisplayName { get; set; }
+ public abstract string Author { get; }
+
+ public abstract string Blurb { get; }
+ public abstract IEnumerable CompatibleWith { get; }
+ public abstract string MainBranch { get; }
+
+ public abstract Task InstallExtensionAsync(
+ DirectoryPath installDirectory,
+ string branch,
+ CancellationToken cancellationToken = default
+ );
+
+ public string GithubUrl => $"https://github.com/{Author}/{RepoName}";
+}
diff --git a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs
index fa3adb34..c38c0e1f 100644
--- a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs
+++ b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs
@@ -32,7 +32,7 @@ public class KohyaSs(
public override Uri PreviewImageUri =>
new(
- "https://camo.githubusercontent.com/2170d2204816f428eec57ff87218f06344e0b4d91966343a6c5f0a76df91ec75/68747470733a2f2f696d672e796f75747562652e636f6d2f76692f6b35696d713031757655592f302e6a7067"
+ "https://camo.githubusercontent.com/5154eea62c113d5c04393e51a0d0f76ef25a723aad29d256dcc85ead1961cd41/68747470733a2f2f696d672e796f75747562652e636f6d2f76692f6b35696d713031757655592f302e6a7067"
);
public override string OutputFolderName => string.Empty;
@@ -40,14 +40,16 @@ public class KohyaSs(
public override TorchVersion GetRecommendedTorchVersion() => TorchVersion.Cuda;
- public override string Disclaimer => "Nvidia GPU with at least 8GB VRAM is recommended. May be unstable on Linux.";
+ public override string Disclaimer =>
+ "Nvidia GPU with at least 8GB VRAM is recommended. May be unstable on Linux.";
public override PackageDifficulty InstallerSortOrder => PackageDifficulty.UltraNightmare;
-
+ public override PackageType PackageType => PackageType.SdTraining;
public override bool OfferInOneClickInstaller => false;
public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.None;
- public override IEnumerable AvailableTorchVersions => new[] { TorchVersion.Cuda };
- public override IEnumerable AvailableSharedFolderMethods => new[] { SharedFolderMethod.None };
+ public override IEnumerable AvailableTorchVersions => [TorchVersion.Cuda];
+ public override IEnumerable AvailableSharedFolderMethods =>
+ new[] { SharedFolderMethod.None };
public override List LaunchOptions =>
[
@@ -189,7 +191,9 @@ public class KohyaSs(
"""
);
- var replacementAcceleratePath = Compat.IsWindows ? @".\venv\scripts\accelerate" : "./venv/bin/accelerate";
+ var replacementAcceleratePath = Compat.IsWindows
+ ? @".\venv\scripts\accelerate"
+ : "./venv/bin/accelerate";
var replacer = scope.InvokeMethod(
"StringReplacer",
@@ -238,7 +242,6 @@ public class KohyaSs(
var args = $"\"{Path.Combine(installedPackagePath, command)}\" {arguments}";
- VenvRunner.EnvironmentVariables = GetEnvVars(installedPackagePath);
VenvRunner.RunDetached(args.TrimEnd(), HandleConsoleOutput, OnExit);
}
@@ -246,23 +249,4 @@ public class KohyaSs(
public override Dictionary>? SharedOutputFolders { get; }
public override string MainBranch => "master";
-
- private Dictionary GetEnvVars(string installDirectory)
- {
- // Set additional required environment variables
- var env = new Dictionary();
- if (SettingsManager.Settings.EnvironmentVariables is not null)
- {
- env.Update(SettingsManager.Settings.EnvironmentVariables);
- }
-
- if (!Compat.IsWindows)
- return env;
-
- var tkPath = Path.Combine(SettingsManager.LibraryDir, "Assets", "Python310", "tcl", "tcl8.6");
- env["TCL_LIBRARY"] = tkPath;
- env["TK_LIBRARY"] = tkPath;
-
- return env;
- }
}
diff --git a/StabilityMatrix.Core/Models/Packages/OneTrainer.cs b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs
new file mode 100644
index 00000000..d113cb2d
--- /dev/null
+++ b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs
@@ -0,0 +1,95 @@
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Helper.Cache;
+using StabilityMatrix.Core.Helper.HardwareInfo;
+using StabilityMatrix.Core.Models.Progress;
+using StabilityMatrix.Core.Processes;
+using StabilityMatrix.Core.Python;
+using StabilityMatrix.Core.Services;
+
+namespace StabilityMatrix.Core.Models.Packages;
+
+[Singleton(typeof(BasePackage))]
+public class OneTrainer(
+ IGithubApiCache githubApi,
+ ISettingsManager settingsManager,
+ IDownloadService downloadService,
+ IPrerequisiteHelper prerequisiteHelper
+) : BaseGitPackage(githubApi, settingsManager, downloadService, prerequisiteHelper)
+{
+ public override string Name => "OneTrainer";
+ public override string DisplayName { get; set; } = "OneTrainer";
+ public override string Author => "Nerogar";
+ public override string Blurb =>
+ "OneTrainer is a one-stop solution for all your stable diffusion training needs";
+ public override string LicenseType => "AGPL-3.0";
+ public override string LicenseUrl => "https://github.com/Nerogar/OneTrainer/blob/master/LICENSE.txt";
+ public override string LaunchCommand => "scripts/train_ui.py";
+
+ public override Uri PreviewImageUri =>
+ new("https://github.com/Nerogar/OneTrainer/blob/master/resources/icons/icon.png?raw=true");
+
+ public override string OutputFolderName => string.Empty;
+ public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.None;
+ public override IEnumerable AvailableTorchVersions => [TorchVersion.Cuda];
+ public override bool IsCompatible => HardwareHelper.HasNvidiaGpu();
+ public override PackageType PackageType => PackageType.SdTraining;
+ public override IEnumerable AvailableSharedFolderMethods =>
+ new[] { SharedFolderMethod.None };
+ public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Nightmare;
+ public override bool OfferInOneClickInstaller => false;
+ public override bool ShouldIgnoreReleases => true;
+
+ public override async Task InstallPackage(
+ string installLocation,
+ TorchVersion torchVersion,
+ SharedFolderMethod selectedSharedFolderMethod,
+ DownloadPackageVersionOptions versionOptions,
+ IProgress? progress = null,
+ Action? onConsoleOutput = null
+ )
+ {
+ progress?.Report(new ProgressReport(-1f, "Setting up venv", isIndeterminate: true));
+
+ await using var venvRunner = new PyVenvRunner(Path.Combine(installLocation, "venv"));
+ venvRunner.WorkingDirectory = installLocation;
+ await venvRunner.Setup(true, onConsoleOutput).ConfigureAwait(false);
+
+ progress?.Report(new ProgressReport(-1f, "Installing requirements", isIndeterminate: true));
+
+ var pipArgs = new PipInstallArgs("-r", "requirements.txt");
+ await venvRunner.PipInstall(pipArgs, onConsoleOutput).ConfigureAwait(false);
+ }
+
+ public override async Task RunPackage(
+ string installedPackagePath,
+ string command,
+ string arguments,
+ Action? onConsoleOutput
+ )
+ {
+ await SetupVenv(installedPackagePath).ConfigureAwait(false);
+ var args = $"\"{Path.Combine(installedPackagePath, command)}\" {arguments}";
+
+ VenvRunner?.RunDetached(args.TrimEnd(), HandleConsoleOutput, HandleExit);
+ return;
+
+ void HandleExit(int i)
+ {
+ Debug.WriteLine($"Venv process exited with code {i}");
+ OnExit(i);
+ }
+
+ void HandleConsoleOutput(ProcessOutput s)
+ {
+ onConsoleOutput?.Invoke(s);
+ }
+ }
+
+ public override List LaunchOptions => [LaunchOptionDefinition.Extras];
+ public override Dictionary>? SharedFolders { get; }
+ public override Dictionary>? SharedOutputFolders { get; }
+ public override string MainBranch => "master";
+}
diff --git a/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs b/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs
index 7eeae2f2..c35b37b8 100644
--- a/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs
+++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs
@@ -31,7 +31,6 @@ public class StableDiffusionDirectMl(
public override string LaunchCommand => "launch.py";
public override Uri PreviewImageUri =>
new("https://github.com/lshqqytiger/stable-diffusion-webui-directml/raw/master/screenshot.png");
-
public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink;
public override TorchVersion GetRecommendedTorchVersion() =>
@@ -39,11 +38,6 @@ public class StableDiffusionDirectMl(
public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Recommended;
- public override IEnumerable AvailableTorchVersions =>
- new[] { TorchVersion.Cpu, TorchVersion.DirectMl };
-
- public override bool ShouldIgnoreReleases => true;
-
public override List LaunchOptions
{
get
@@ -64,6 +58,11 @@ public class StableDiffusionDirectMl(
}
}
+ public override IEnumerable AvailableTorchVersions =>
+ new[] { TorchVersion.Cpu, TorchVersion.DirectMl };
+
+ public override bool ShouldIgnoreReleases => true;
+
public override async Task InstallPackage(
string installLocation,
TorchVersion torchVersion,
diff --git a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs
index 1f646d06..8da91d96 100644
--- a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs
+++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs
@@ -28,7 +28,8 @@ public class StableDiffusionUx(
public override string DisplayName { get; set; } = "Stable Diffusion Web UI-UX";
public override string Author => "anapnoe";
public override string LicenseType => "AGPL-3.0";
- public override string LicenseUrl => "https://github.com/anapnoe/stable-diffusion-webui-ux/blob/master/LICENSE.txt";
+ public override string LicenseUrl =>
+ "https://github.com/anapnoe/stable-diffusion-webui-ux/blob/master/LICENSE.txt";
public override string Blurb =>
"A pixel perfect design, mobile friendly, customizable interface that adds accessibility, "
+ "ease of use and extended functionallity to the stable diffusion web ui.";
@@ -38,7 +39,7 @@ public class StableDiffusionUx(
public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink;
- public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Simple;
+ public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Advanced;
public override Dictionary> SharedFolders =>
new()
diff --git a/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
index b6711cd5..c8ea41eb 100644
--- a/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
+++ b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
@@ -11,6 +11,9 @@ public record UpdatePlatforms
[JsonPropertyName("linux-x64")]
public UpdateInfo? LinuxX64 { get; init; }
+ [JsonPropertyName("macos-arm64")]
+ public UpdateInfo? MacOsArm64 { get; init; }
+
public UpdateInfo? GetInfoForCurrentPlatform()
{
if (Compat.IsWindows)
@@ -23,6 +26,11 @@ public record UpdatePlatforms
return LinuxX64;
}
+ if (Compat.IsMacOS && Compat.IsArm)
+ {
+ return MacOsArm64;
+ }
+
return null;
}
}
diff --git a/StabilityMatrix.Core/Processes/ProcessArgs.cs b/StabilityMatrix.Core/Processes/ProcessArgs.cs
index 8fd296b3..d31d0953 100644
--- a/StabilityMatrix.Core/Processes/ProcessArgs.cs
+++ b/StabilityMatrix.Core/Processes/ProcessArgs.cs
@@ -1,4 +1,5 @@
using System.Collections;
+using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using OneOf;
@@ -9,6 +10,7 @@ namespace StabilityMatrix.Core.Processes;
/// Implicitly converts between string and string[],
/// with no parsing if the input and output types are the same.
///
+[CollectionBuilder(typeof(ProcessArgsCollectionBuilder), "Create")]
public partial class ProcessArgs : OneOfBase, IEnumerable
{
///
@@ -19,8 +21,7 @@ public partial class ProcessArgs : OneOfBase, IEnumerable
- public bool Contains(string arg) =>
- Match(str => str.Contains(arg), arr => arr.Any(x => x.Contains(arg)));
+ public bool Contains(string arg) => Match(str => str.Contains(arg), arr => arr.Any(x => x.Contains(arg)));
public ProcessArgs Concat(ProcessArgs other) =>
Match(
@@ -53,10 +54,7 @@ public partial class ProcessArgs : OneOfBase, IEnumerable
- Match(
- str => ArgumentsRegex().Matches(str).Select(x => x.Value.Trim('"')).ToArray(),
- arr => arr
- );
+ Match(str => ArgumentsRegex().Matches(str).Select(x => x.Value.Trim('"')).ToArray(), arr => arr);
// Implicit conversions
@@ -71,3 +69,8 @@ public partial class ProcessArgs : OneOfBase, IEnumerable values) => new(values.ToArray());
+}
diff --git a/StabilityMatrix.Core/Processes/ProcessResult.cs b/StabilityMatrix.Core/Processes/ProcessResult.cs
index 9fca6171..509f7ab1 100644
--- a/StabilityMatrix.Core/Processes/ProcessResult.cs
+++ b/StabilityMatrix.Core/Processes/ProcessResult.cs
@@ -22,3 +22,13 @@ public readonly record struct ProcessResult
}
}
}
+
+public static class ProcessResultTaskExtensions
+{
+ public static async Task EnsureSuccessExitCode(this Task task)
+ {
+ var result = await task.ConfigureAwait(false);
+ result.EnsureSuccessExitCode();
+ return result;
+ }
+}
diff --git a/StabilityMatrix.Core/Processes/ProcessRunner.cs b/StabilityMatrix.Core/Processes/ProcessRunner.cs
index f29fc72c..868eb247 100644
--- a/StabilityMatrix.Core/Processes/ProcessRunner.cs
+++ b/StabilityMatrix.Core/Processes/ProcessRunner.cs
@@ -92,7 +92,7 @@ public static class ProcessRunner
else if (Compat.IsMacOS)
{
using var process = new Process();
- process.StartInfo.FileName = "explorer";
+ process.StartInfo.FileName = "open";
process.StartInfo.Arguments = $"-R {Quote(filePath)}";
process.Start();
await process.WaitForExitAsync().ConfigureAwait(false);
@@ -331,19 +331,10 @@ public static class ProcessRunner
{
// Quote arguments containing spaces
var args = string.Join(" ", arguments.Where(s => !string.IsNullOrEmpty(s)).Select(Quote));
- return StartAnsiProcess(
- fileName,
- args,
- workingDirectory,
- outputDataReceived,
- environmentVariables
- );
+ return StartAnsiProcess(fileName, args, workingDirectory, outputDataReceived, environmentVariables);
}
- public static async Task RunBashCommand(
- string command,
- string workingDirectory = ""
- )
+ public static async Task RunBashCommand(string command, string workingDirectory = "")
{
// Escape any single quotes in the command
var escapedCommand = command.Replace("\"", "\\\"");
@@ -381,10 +372,7 @@ public static class ProcessRunner
};
}
- public static Task RunBashCommand(
- IEnumerable commands,
- string workingDirectory = ""
- )
+ public static Task RunBashCommand(IEnumerable commands, string workingDirectory = "")
{
// Quote arguments containing spaces
var args = string.Join(" ", commands.Select(Quote));
@@ -433,9 +421,7 @@ public static class ProcessRunner
catch (SystemException) { }
throw new ProcessException(
- "Process "
- + (processName == null ? "" : processName + " ")
- + $"failed with exit-code {process.ExitCode}."
+ "Process " + (processName == null ? "" : processName + " ") + $"failed with exit-code {process.ExitCode}."
);
}
}
diff --git a/StabilityMatrix.Core/Python/PyRunner.cs b/StabilityMatrix.Core/Python/PyRunner.cs
index b053a281..6e384449 100644
--- a/StabilityMatrix.Core/Python/PyRunner.cs
+++ b/StabilityMatrix.Core/Python/PyRunner.cs
@@ -12,13 +12,7 @@ using StabilityMatrix.Core.Python.Interop;
namespace StabilityMatrix.Core.Python;
[SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Global")]
-public record struct PyVersionInfo(
- int Major,
- int Minor,
- int Micro,
- string ReleaseLevel,
- int Serial
-);
+public record struct PyVersionInfo(int Major, int Minor, int Micro, string ReleaseLevel, int Serial);
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[Singleton(typeof(IPyRunner))]
@@ -32,8 +26,7 @@ public class PyRunner : IPyRunner
// This is same for all platforms
public const string PythonDirName = "Python310";
- public static string PythonDir =>
- Path.Combine(GlobalConfig.LibraryDir, "Assets", PythonDirName);
+ public static string PythonDir => Path.Combine(GlobalConfig.LibraryDir, "Assets", PythonDirName);
///
/// Path to the Python Linked library relative from the Python directory.
@@ -61,8 +54,7 @@ public class PyRunner : IPyRunner
public static string GetPipPath => Path.Combine(PythonDir, "get-pip.pyc");
- public static string VenvPath =>
- Path.Combine(PythonDir, "Scripts", "virtualenv" + Compat.ExeExtension);
+ public static string VenvPath => Path.Combine(PythonDir, "Scripts", "virtualenv" + Compat.ExeExtension);
public static bool PipInstalled => File.Exists(PipExePath);
public static bool VenvInstalled => File.Exists(VenvPath);
@@ -107,8 +99,7 @@ public class PyRunner : IPyRunner
await RunInThreadWithLock(() =>
{
var sys =
- Py.Import("sys") as PyModule
- ?? throw new NullReferenceException("sys module not found");
+ Py.Import("sys") as PyModule ?? throw new NullReferenceException("sys module not found");
sys.Set("stdout", StdOutStream);
sys.Set("stderr", StdErrStream);
})
@@ -141,7 +132,7 @@ public class PyRunner : IPyRunner
throw new FileNotFoundException("pip not found", PipExePath);
}
var result = await ProcessRunner
- .GetProcessResultAsync(PipExePath, $"install {package}")
+ .GetProcessResultAsync(PythonExePath, $"-m pip install {package}")
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
}
diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs
index ed4a5763..dffbb79c 100644
--- a/StabilityMatrix.Core/Services/DownloadService.cs
+++ b/StabilityMatrix.Core/Services/DownloadService.cs
@@ -43,7 +43,12 @@ public class DownloadService : IDownloadService
await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false);
- await using var file = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ await using var file = new FileStream(
+ downloadPath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.None
+ );
long contentLength = 0;
@@ -81,7 +86,9 @@ public class DownloadService : IDownloadService
}
}
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using var stream = await response
+ .Content.ReadAsStreamAsync(cancellationToken)
+ .ConfigureAwait(false);
var totalBytesRead = 0L;
var buffer = new byte[BufferSize];
while (true)
@@ -143,7 +150,12 @@ public class DownloadService : IDownloadService
File.Create(downloadPath).Close();
}
- await using var file = new FileStream(downloadPath, FileMode.Append, FileAccess.Write, FileShare.None);
+ await using var file = new FileStream(
+ downloadPath,
+ FileMode.Append,
+ FileAccess.Write,
+ FileShare.None
+ );
// Remaining content length
long remainingContentLength = 0;
@@ -156,7 +168,9 @@ public class DownloadService : IDownloadService
noRedirectRequest.Headers.Range = new RangeHeaderValue(existingFileSize, null);
HttpResponseMessage? response = null;
- foreach (var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 4))
+ foreach (
+ var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 4)
+ )
{
var noRedirectResponse = await noRedirectClient
.SendAsync(noRedirectRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
@@ -197,7 +211,9 @@ public class DownloadService : IDownloadService
var isIndeterminate = remainingContentLength == 0;
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using var stream = await response
+ .Content.ReadAsStreamAsync(cancellationToken)
+ .ConfigureAwait(false);
var totalBytesRead = 0L;
var buffer = new byte[BufferSize];
while (true)
@@ -252,7 +268,9 @@ public class DownloadService : IDownloadService
var contentLength = 0L;
- foreach (var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 3))
+ foreach (
+ var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 3)
+ )
{
var response = await client
.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
@@ -303,7 +321,10 @@ public class DownloadService : IDownloadService
ObjectHash.GetStringSignature(civitApi.ApiToken),
url
);
- client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", civitApi.ApiToken);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
+ "Bearer",
+ civitApi.ApiToken
+ );
}
}
}
diff --git a/StabilityMatrix.Core/Services/IModelIndexService.cs b/StabilityMatrix.Core/Services/IModelIndexService.cs
index 83c7940b..69013b5b 100644
--- a/StabilityMatrix.Core/Services/IModelIndexService.cs
+++ b/StabilityMatrix.Core/Services/IModelIndexService.cs
@@ -12,18 +12,28 @@ public interface IModelIndexService
///
Task RefreshIndex();
+ ///
+ /// Starts a background task to refresh the local model file index.
+ ///
+ void BackgroundRefreshIndex();
+
///
/// Get all models of the specified type from the existing (in-memory) index.
///
IEnumerable GetFromModelIndex(SharedFolderType types);
///
- /// Get all models of the specified type from the existing index.
+ /// Find all models of the specified SharedFolderType.
///
- Task> GetModelsOfType(SharedFolderType type);
+ Task> FindAsync(SharedFolderType type);
///
- /// Starts a background task to refresh the local model file index.
+ /// Find all models with the specified Blake3 hash.
///
- void BackgroundRefreshIndex();
+ Task> FindByHashAsync(string hashBlake3);
+
+ ///
+ /// Remove a model from the index.
+ ///
+ Task RemoveModelAsync(LocalModelFile model);
}
diff --git a/StabilityMatrix.Core/Services/MetadataImportService.cs b/StabilityMatrix.Core/Services/MetadataImportService.cs
index 5a88fb83..ca1803b4 100644
--- a/StabilityMatrix.Core/Services/MetadataImportService.cs
+++ b/StabilityMatrix.Core/Services/MetadataImportService.cs
@@ -1,4 +1,5 @@
-using System.Text.Json;
+using System.Diagnostics;
+using System.Text.Json;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs
index 67a67669..bd0182b3 100644
--- a/StabilityMatrix.Core/Services/ModelIndexService.cs
+++ b/StabilityMatrix.Core/Services/ModelIndexService.cs
@@ -1,6 +1,9 @@
-using System.Diagnostics;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Diagnostics;
using System.Text;
using AsyncAwaitBestPractices;
+using AutoCtor;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Database;
@@ -13,45 +16,44 @@ using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Core.Services;
[Singleton(typeof(IModelIndexService))]
-public class ModelIndexService : IModelIndexService
+[AutoConstruct]
+public partial class ModelIndexService : IModelIndexService
{
private readonly ILogger logger;
- private readonly ILiteDbContext liteDbContext;
private readonly ISettingsManager settingsManager;
+ private readonly ILiteDbContext liteDbContext;
public Dictionary> ModelIndex { get; private set; } = new();
- public ModelIndexService(
- ILogger logger,
- ILiteDbContext liteDbContext,
- ISettingsManager settingsManager
- )
+ [AutoPostConstruct]
+ private void Initialize()
{
- this.logger = logger;
- this.liteDbContext = liteDbContext;
- this.settingsManager = settingsManager;
+ // Start background index when library dir is set
+ settingsManager.RegisterOnLibraryDirSet(_ => BackgroundRefreshIndex());
}
- ///
- /// Deletes all entries in the local model file index.
- ///
- private async Task ClearIndex()
+ public IEnumerable GetFromModelIndex(SharedFolderType types)
{
- await liteDbContext.LocalModelFiles.DeleteAllAsync().ConfigureAwait(false);
+ return ModelIndex.Where(kvp => (kvp.Key & types) != 0).SelectMany(kvp => kvp.Value);
}
- public IEnumerable GetFromModelIndex(SharedFolderType types)
+ ///
+ public async Task> FindAsync(SharedFolderType type)
{
- return ModelIndex.Where(kvp => (kvp.Key & types) != 0).SelectMany(kvp => kvp.Value);
+ await liteDbContext.LocalModelFiles.EnsureIndexAsync(m => m.SharedFolderType).ConfigureAwait(false);
+
+ return await liteDbContext
+ .LocalModelFiles.FindAsync(m => m.SharedFolderType == type)
+ .ConfigureAwait(false);
}
///
- public async Task> GetModelsOfType(SharedFolderType type)
+ public async Task> FindByHashAsync(string hashBlake3)
{
+ await liteDbContext.LocalModelFiles.EnsureIndexAsync(m => m.HashBlake3).ConfigureAwait(false);
+
return await liteDbContext
- .LocalModelFiles.Query()
- .Where(m => m.SharedFolderType == type)
- .ToArrayAsync()
+ .LocalModelFiles.FindAsync(m => m.HashBlake3 == hashBlake3)
.ConfigureAwait(false);
}
@@ -86,6 +88,7 @@ public class ModelIndexService : IModelIndexService
var added = 0;
var newIndex = new Dictionary>();
+
foreach (
var file in modelsDir
.Info.EnumerateFiles("*.*", SearchOption.AllDirectories)
@@ -161,6 +164,7 @@ public class ModelIndexService : IModelIndexService
// Add to index
var list = newIndex.GetOrAdd(sharedFolderType);
list.Add(localModel);
+
added++;
}
@@ -195,4 +199,24 @@ public class ModelIndexService : IModelIndexService
logger.LogError(ex, "Error in background model indexing");
});
}
+
+ ///
+ public async Task RemoveModelAsync(LocalModelFile model)
+ {
+ // Remove from database
+ if (await liteDbContext.LocalModelFiles.DeleteAsync(model.RelativePath).ConfigureAwait(false))
+ {
+ // Remove from index
+ if (ModelIndex.TryGetValue(model.SharedFolderType, out var list))
+ {
+ list.Remove(model);
+ }
+
+ EventManager.Instance.OnModelIndexChanged();
+
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs
index dc180367..61e04aa7 100644
--- a/StabilityMatrix.Core/Services/SettingsManager.cs
+++ b/StabilityMatrix.Core/Services/SettingsManager.cs
@@ -675,7 +675,20 @@ public class SettingsManager : ISettingsManager
SettingsSerializerContext.Default.Settings
);
- File.WriteAllBytes(SettingsPath, jsonBytes);
+ if (jsonBytes.Length == 0)
+ {
+ Logger.Error("JsonSerializer returned empty bytes for some reason");
+ return;
+ }
+
+ using var fs = File.Open(SettingsPath, FileMode.Open);
+ if (fs.CanWrite)
+ {
+ fs.Write(jsonBytes, 0, jsonBytes.Length);
+ fs.Flush();
+ fs.SetLength(jsonBytes.Length);
+ }
+ fs.Close();
}
finally
{
diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj
index 6697dbee..44f76ba6 100644
--- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj
+++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj
@@ -17,6 +17,7 @@
+
@@ -25,9 +26,13 @@
+
+
+
+
@@ -49,6 +54,7 @@
+
diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs
index 7a7a1289..eff50ce3 100644
--- a/StabilityMatrix.Core/Updater/UpdateHelper.cs
+++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs
@@ -10,6 +10,7 @@ using StabilityMatrix.Core.Models.Configs;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update;
+using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Updater;
@@ -88,10 +89,7 @@ public class UpdateHelper : IUpdateHelper
)
)
{
- logger.LogInformation(
- "Handling authenticated update download: {Url}",
- updateInfo.Url
- );
+ logger.LogInformation("Handling authenticated update download: {Url}", updateInfo.Url);
var path = updateInfo.Url.PathAndQuery.StripStart(authedPathPrefix);
path = HttpUtility.UrlDecode(path);
@@ -102,12 +100,7 @@ public class UpdateHelper : IUpdateHelper
// Download update
await downloadService
- .DownloadToFileAsync(
- url,
- downloadFile,
- progress: progress,
- httpClientName: "UpdateClient"
- )
+ .DownloadToFileAsync(url, downloadFile, progress: progress, httpClientName: "UpdateClient")
.ConfigureAwait(false);
// Unzip if needed
@@ -119,11 +112,12 @@ public class UpdateHelper : IUpdateHelper
}
extractDir.Create();
- progress.Report(
- new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract)
- );
+ progress.Report(new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract));
+
await ArchiveHelper.Extract(downloadFile, extractDir).ConfigureAwait(false);
+ progress.Report(new ProgressReport(1, isIndeterminate: true, type: ProgressType.Extract));
+
// Find binary and move it up to the root
var binaryFile = extractDir
.EnumerateFiles("*.*", SearchOption.AllDirectories)
@@ -131,6 +125,24 @@ public class UpdateHelper : IUpdateHelper
await binaryFile.MoveToAsync(ExecutablePath).ConfigureAwait(false);
}
+ else if (downloadFile.Extension == ".dmg")
+ {
+ if (!Compat.IsMacOS)
+ throw new NotSupportedException(".dmg is only supported on macOS");
+
+ if (extractDir.Exists)
+ {
+ await extractDir.DeleteAsync(true).ConfigureAwait(false);
+ }
+ extractDir.Create();
+
+ // Extract dmg contents
+ await ArchiveHelper.ExtractDmg(downloadFile, extractDir).ConfigureAwait(false);
+
+ // Find app and move it up to the root
+ var appFile = extractDir.EnumerateFiles("*.app").First();
+ await appFile.MoveToAsync(ExecutablePath).ConfigureAwait(false);
+ }
// Otherwise just rename
else
{
@@ -184,9 +196,7 @@ public class UpdateHelper : IUpdateHelper
var channel in Enum.GetValues(typeof(UpdateChannel))
.Cast()
.Where(
- c =>
- c > UpdateChannel.Unknown
- && c <= settingsManager.Settings.PreferredUpdateChannel
+ c => c > UpdateChannel.Unknown && c <= settingsManager.Settings.PreferredUpdateChannel
)
)
{
@@ -295,11 +305,7 @@ public class UpdateHelper : IUpdateHelper
private void NotifyUpdateAvailable(UpdateInfo update)
{
- logger.LogInformation(
- "Update available {AppVer} -> {UpdateVer}",
- Compat.AppVersion,
- update.Version
- );
+ logger.LogInformation("Update available {AppVer} -> {UpdateVer}", Compat.AppVersion, update.Version);
EventManager.Instance.OnUpdateAvailable(update);
}
}
diff --git a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj
index 87423475..a0acdc33 100644
--- a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj
+++ b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/StabilityMatrix.UITests/Extensions/WindowExtensions.cs b/StabilityMatrix.UITests/Extensions/WindowExtensions.cs
index 922ea147..f86ad7b9 100644
--- a/StabilityMatrix.UITests/Extensions/WindowExtensions.cs
+++ b/StabilityMatrix.UITests/Extensions/WindowExtensions.cs
@@ -19,16 +19,12 @@ public static class WindowExtensions
}
if (targetVisualRoot.Equals(topLevel))
{
- throw new ArgumentException(
- "Target is not part of the same visual tree as the top level"
- );
+ throw new ArgumentException("Target is not part of the same visual tree as the top level");
}
var point =
- target.TranslatePoint(
- new Point(target.Bounds.Width / 2, target.Bounds.Height / 2),
- topLevel
- ) ?? throw new NullReferenceException("Point is null");
+ target.TranslatePoint(new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), topLevel)
+ ?? throw new NullReferenceException("Point is null");
topLevel.MouseMove(point);
topLevel.MouseDown(point, MouseButton.Left);
@@ -48,16 +44,12 @@ public static class WindowExtensions
}
if (!targetVisualRoot.Equals(topLevel))
{
- throw new ArgumentException(
- "Target is not part of the same visual tree as the top level"
- );
+ throw new ArgumentException("Target is not part of the same visual tree as the top level");
}
var point =
- target.TranslatePoint(
- new Point(target.Bounds.Width / 2, target.Bounds.Height / 2),
- topLevel
- ) ?? throw new NullReferenceException("Point is null");
+ target.TranslatePoint(new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), topLevel)
+ ?? throw new NullReferenceException("Point is null");
topLevel.MouseMove(point);
topLevel.MouseDown(point, MouseButton.Left);
@@ -68,6 +60,6 @@ public static class WindowExtensions
// Return mouse to outside of window
topLevel.MouseMove(new Point(-50, -50));
- Dispatcher.UIThread.Invoke(() => Dispatcher.UIThread.RunJobs());
+ await Task.Delay(300);
}
}
diff --git a/StabilityMatrix.UITests/MainWindowTests.cs b/StabilityMatrix.UITests/MainWindowTests.cs
index cc2c3f13..1c002cf5 100644
--- a/StabilityMatrix.UITests/MainWindowTests.cs
+++ b/StabilityMatrix.UITests/MainWindowTests.cs
@@ -1,16 +1,9 @@
using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
-using Avalonia.Threading;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
-using FluentAvalonia.UI.Windowing;
using Microsoft.Extensions.DependencyInjection;
-using StabilityMatrix.Avalonia;
-using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
-using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
-using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.UITests.Extensions;
namespace StabilityMatrix.UITests;
@@ -18,126 +11,19 @@ namespace StabilityMatrix.UITests;
[UsesVerify]
[Collection("TempDir")]
[TestCaseOrderer("StabilityMatrix.UITests.PriorityOrderer", "StabilityMatrix.UITests")]
-public class MainWindowTests
+public class MainWindowTests : TestBase
{
- private static IServiceProvider Services => App.Services;
-
- private static (AppWindow, MainWindowViewModel)? currentMainWindow;
-
- private static VerifySettings Settings
- {
- get
- {
- var settings = new VerifySettings();
- settings.IgnoreMembers(
- vm => vm.Pages,
- vm => vm.FooterPages,
- vm => vm.CurrentPage
- );
- settings.IgnoreMember(vm => vm.CurrentVersionText);
- settings.DisableDiff();
- return settings;
- }
- }
-
- private static (AppWindow, MainWindowViewModel) GetMainWindow()
- {
- if (currentMainWindow is not null)
- {
- return currentMainWindow.Value;
- }
-
- var window = Services.GetRequiredService();
- var viewModel = Services.GetRequiredService();
- window.DataContext = viewModel;
-
- window.SetDefaultFonts();
- window.Width = 1400;
- window.Height = 900;
-
- App.VisualRoot = window;
- App.StorageProvider = window.StorageProvider;
- App.Clipboard = window.Clipboard ?? throw new NullReferenceException("Clipboard is null");
-
- currentMainWindow = (window, viewModel);
- return currentMainWindow.Value;
- }
-
- private static BetterContentDialog? GetWindowDialog(Visual window)
- {
- return window
- .FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType();
- }
-
- private static IEnumerable EnumerateWindowDialogs(Visual window)
- {
- return window
- .FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.FindDescendantOfType()
- ?.GetVisualDescendants()
- .OfType() ?? Enumerable.Empty();
- }
-
- private async Task<(BetterContentDialog, T)> WaitForDialog(Visual window)
- where T : Control
- {
- var dialogs = await WaitHelper.WaitForConditionAsync(
- () => EnumerateWindowDialogs(window).ToList(),
- list => list.Any(dialog => dialog.Content is T)
- );
-
- if (dialogs.Count == 0)
- {
- throw new InvalidOperationException("No dialogs found");
- }
-
- var contentDialog = dialogs.First(dialog => dialog.Content is T);
-
- return (contentDialog, contentDialog.Content as T)!;
- }
-
[AvaloniaFact, TestPriority(1)]
public async Task MainWindow_ShouldOpen()
{
var (window, _) = GetMainWindow();
window.Show();
-
await Task.Delay(300);
- Dispatcher.UIThread.RunJobs();
-
- // Find the select data directory dialog
- var selectDataDirectoryDialog = await WaitHelper.WaitForNotNullAsync(
- () => GetWindowDialog(window)
- );
- Assert.NotNull(selectDataDirectoryDialog);
-
- // Click continue button
- var continueButton = selectDataDirectoryDialog
- .GetVisualDescendants()
- .OfType