diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88b5eae37..aeffd47d7 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/.gitignore b/.gitignore index 37169d9f1..a37a873d3 100644 --- a/.gitignore +++ b/.gitignore @@ -397,3 +397,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.husky/pre-commit diff --git a/Avalonia.Gif/Avalonia.Gif.csproj b/Avalonia.Gif/Avalonia.Gif.csproj new file mode 100644 index 000000000..858b974e5 --- /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 000000000..aebd44dd8 --- /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 000000000..1b09bba15 --- /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 000000000..2d804d5b4 --- /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 000000000..5db6d575e --- /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 000000000..bf4f00b7a --- /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 000000000..222d73031 --- /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 000000000..adc26bb0e --- /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 000000000..ea0e6640a --- /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 000000000..16638f790 --- /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 000000000..01f621de6 --- /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 000000000..4b27a7bb2 --- /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 000000000..b3554bac4 --- /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 000000000..ed25c0aad --- /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 000000000..ac08fa68d --- /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 000000000..0ce8dca84 --- /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 000000000..b97badaa5 --- /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 000000000..667f91636 --- /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 000000000..9771d9cb9 --- /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 000000000..b92569349 --- /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 000000000..885de0b58 --- /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 000000000..c4317486d --- /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 000000000..acdfcd4b3 --- /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 000000000..c0a99ea29 --- /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 000000000..5a05fc74a --- /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 000000000..7fae9ccf7 --- /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 05818a093..c9787d1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,171 @@ 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 +### Added +- Added Image to Video project type +- Added CLIP Skip setting to inference, toggleable from the model settings button +- Added image and model details in model selection boxes +- Added new package: [OneTrainer](https://github.com/Nerogar/OneTrainer) +- Added native desktop push notifications for some events (i.e. Downloads, Package installs, Inference generation) + - Currently available on Windows and Linux, macOS support is pending +- Added Package Extensions (Plugins) management - accessible from the Packages' 3-dot menu. Currently supports ComfyUI and Automatic1111. +- Added new launch argument options for Fooocus +- Added "Config" Shared Model Folder option for Fooocus +- Added Recommended Models dialog after one-click installer +- Added "Copy Details" button to Unexpected Error dialog +- Added German language option, thanks to Mario da Graca for the translation +- Added Portuguese language options, thanks to nextosai for the translation +- Added base model filter to Checkpoints page +- Added "Compatible Images" category when selecting images for Inference projects +- Added "Find in Model Browser" option to the right-click menu on the Checkpoints page +- Added `--use-directml` launch argument for SDWebUI DirectML fork +- Added release builds for macOS (Apple Silicon) +- 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 +- Added support for webp files to the Output Browser +- Added "Send to Image to Image" and "Send to Image to Video" options to the context menu +### Changed +- New package installation flow +- Changed one-click installer to match the new package installation style +- Automatic1111 packages will now use PyTorch v2.1.2. Upgrade will occur during the next package update or upon fresh installation. +- Search box on Checkpoints page now searches tags and trigger words +- Changed the Close button on the package install dialog to "Hide" + - Functionality remains the same, just a name change +- Updated translations for the following languages: + - Spanish + - French + - Japanese + - Turkish +- Inference file name patterns with directory separator characters will now have the subdirectories created automatically +- Changed how settings file is written to disk to reduce potential data loss risk +- (Internal) Updated to Avalonia 11.0.7 +### Fixed +- Fixed error when ControlNet module image paths are not found, even if the module is disabled +- Fixed error when finding metadata for archived models +- Fixed error when extensions folder is missing +- Fixed crash when model was not selected in Inference +- Fixed Fooocus Config shared folder mode overwriting unknown config keys +- Fixed potential SD.Next update issues by moving to shared update process +- Fixed crash on startup when Outputs page failed to load categories properly +- Fixed image gallery arrow key navigation requiring clicking before responding +- Fixed crash when loading extensions list with no internet connection +- Fixed crash when invalid launch arguments are passed +- Fixed missing up/downgrade buttons on the Python Packages dialog when the version was not semver compatible + + +## v2.8.0-pre.5 +### Fixed +- Fixed error when ControlNet module image paths are not found, even if the module is disabled +- Fixed error when finding metadata for archived models +- Fixed error when extensions folder is missing +- Fixed error when webp files have incorrect metadata +- Fixed crash when model was not selected in Inference +- Fixed Fooocus Config shared folder mode overwriting unknown config keys + +## v2.8.0-pre.4 +### Added +- Added Recommended Models dialog after one-click installer +- Added native desktop push notifications for some events (i.e. Downloads, Package installs, Inference generation) + - Currently available on Windows and Linux, macOS support is pending +- Added settings options for notifications +- Added new launch argument options for Fooocus +- Added Automatic1111 & Stable Diffusion WebUI-UX to the compatible macOS packages +### Changed +- Changed one-click installer to match the new package installation style +- Automatic1111 packages will now use PyTorch v2.1.2. Upgrade will occur during the next package update or upon fresh installation. +- Updated French translation with the latest changes +### Fixed +- Fixed [#413](https://github.com/LykosAI/StabilityMatrix/issues/413) - Environment Variables are editable again +- Fixed potential SD.Next update issues by moving to shared update process +- Fixed Invoke install trying to use system nodejs +- Fixed crash on startup when Outputs page failed to load categories properly + +## v2.8.0-pre.3 +### Added +- Added "Config" Shared Model Folder option for Fooocus +- Added "Copy Details" button to Unexpected Error dialog +### Changed +- (Internal) Updated to Avalonia 11.0.7 +- Changed the Close button on the package install dialog to "Hide" + - Functionality remains the same, just a name change +- Updated French translation (thanks Greg!) +### Fixed +- Webp static images can now be shown alongside existing webp animation support +- Fixed image gallery arrow key navigation requiring clicking before responding +- Fixed crash when loading extensions list with no internet connection +- Fixed crash when invalid launch arguments are passed +- Fixed "must give at least one requirement to install" error when installing extensions with empty requirements.txt + +## v2.8.0-pre.2 +### Added +- Added German language option, thanks to Mario da Graca for the translation +- Added Portuguese language options, thanks to nextosai for the translation +### Changed +- Updated translations for the following languages: + - Spanish + - French + - Japanese + - Turkish +### Fixed +- Fixed Auto-update failing to start new version on Windows and Linux when path contains spaces +- Fixed InvokeAI v3.6.0 `"detail": "Not Found"` error when opening the UI +- Install button will now be properly disabled when the duplicate warning is shown + +## v2.8.0-pre.1 +### Added +- Added Package Extensions (Plugins) management - accessible from the Packages' 3-dot menu. Currently supports ComfyUI and A1111. +- Added base model filter to Checkpoints page +- Search box on Checkpoints page now searches tags and trigger words +- Added "Compatible Images" category when selecting images for Inference projects +- Added "Find in Model Browser" option to the right-click menu on the Checkpoints page +### Changed +- Removed "Failed to load image" notification when loading some images on the Checkpoints page +- Installed models will no longer be selectable on the Hugging Face tab of the model browser +### Fixed +- Inference file name patterns with directory separator characters will now have the subdirectories created automatically +- Fixed missing up/downgrade buttons on the Python Packages dialog when the version was not semver compatible +- Automatic1111 package installs will now install the missing `jsonmerge` package + +## 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.9 ### Fixed - Fixed InvokeAI v3.6.0 `"detail": "Not Found"` error when opening the UI diff --git a/README.md b/README.md index a5f348dd1..8c0517965 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 @@ -106,6 +104,10 @@ Stability Matrix is now available in the following languages, thanks to our comm - aolko - 🇹🇷 Türkçe - Progesor +- 🇩🇪 Deutsch + - Mario da Graca +- 🇵🇹 Português + - nextosai If you would like to contribute a translation, please create an issue or contact us on Discord. Include an email where we'll send an invite to our [POEditor](https://poeditor.com/) project. diff --git a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj index eb84e3f36..e635ad2f4 100644 --- a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj +++ b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj @@ -19,12 +19,13 @@ - - - - - - + + + + + + + diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index b3d397711..ad48795c4 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 @@ + @@ -51,6 +53,7 @@ + @@ -58,6 +61,8 @@ + + diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 742b10a35..655f0ad05 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -17,11 +17,13 @@ using Avalonia.Data.Core.Plugins; using Avalonia.Input.Platform; using Avalonia.Markup.Xaml; +using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; +using FluentAvalonia.Interop; using FluentAvalonia.UI.Controls; using MessagePipe; using Microsoft.Extensions.Configuration; @@ -80,7 +82,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; } @@ -96,6 +99,8 @@ public sealed class App : Application public IClassicDesktopStyleApplicationLifetime? DesktopLifetime => ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + public static new App? Current => (App?)Application.Current; + /// /// Called before is built. /// Can be used by UI tests to override services. @@ -106,6 +111,8 @@ public override void Initialize() { AvaloniaXamlLoader.Load(this); + SetFontFamily(GetPlatformDefaultFontFamily()); + // Set design theme if (Design.IsDesignMode) { @@ -117,8 +124,7 @@ public override void OnFrameworkInitializationCompleted() { // 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 +167,19 @@ public override void OnFrameworkInitializationCompleted() DesktopLifetime.MainWindow = setupWindow; - setupWindow - .ShowAsyncCts - .Token - .Register(() => + setupWindow.ShowAsyncCts.Token.Register(() => + { + if (setupWindow.Result == ContentDialogResult.Primary) { - if (setupWindow.Result == ContentDialogResult.Primary) - { - settingsManager.SetEulaAccepted(); - ShowMainWindow(); - DesktopLifetime.MainWindow.Show(); - } - else - { - Shutdown(); - } - }); + settingsManager.SetEulaAccepted(); + ShowMainWindow(); + DesktopLifetime.MainWindow.Show(); + } + else + { + Shutdown(); + } + }); } else { @@ -185,6 +188,58 @@ public override void OnFrameworkInitializationCompleted() } } + /// + /// Set the default font family for the application. + /// + private void SetFontFamily(FontFamily fontFamily) + { + Resources["ContentControlThemeFontFamily"] = fontFamily; + } + + /// + /// Get the default font family for the current platform and language. + /// + public FontFamily GetPlatformDefaultFontFamily() + { + try + { + var fonts = new List(); + + if (Cultures.Current?.Name == "ja-JP") + { + return Resources["NotoSansJP"] as FontFamily + ?? throw new ApplicationException("Font NotoSansJP not found"); + } + + if (Compat.IsWindows) + { + fonts.Add(OSVersionHelper.IsWindows11() ? "Segoe UI Variable Text" : "Segoe UI"); + } + else if (Compat.IsMacOS) + { + // Use Segoe fonts if installed, but we can't distribute them + fonts.Add("Segoe UI Variable"); + fonts.Add("Segoe UI"); + + fonts.Add("San Francisco"); + fonts.Add("Helvetica Neue"); + fonts.Add("Helvetica"); + } + else + { + return FontFamily.Default; + } + + return new FontFamily(string.Join(",", fonts)); + } + catch (Exception e) + { + LogManager.GetCurrentClassLogger().Error(e); + + return FontFamily.Default; + } + } + /// /// Setup tasks to be run shortly before any window is shown /// @@ -223,17 +278,13 @@ private void ShowMainWindow() mainWindow.WindowStartupLocation = WindowStartupLocation.CenterScreen; } - mainWindow.Closing += OnMainWindowClosing; - mainWindow.Closed += (_, _) => Shutdown(); - - mainWindow.SetDefaultFonts(); - VisualRoot = mainWindow; StorageProvider = mainWindow.StorageProvider; Clipboard = mainWindow.Clipboard ?? throw new NullReferenceException("Clipboard is null"); DesktopLifetime.MainWindow = mainWindow; DesktopLifetime.Exit += OnExit; + DesktopLifetime.ShutdownRequested += OnShutdownRequested; } private static void ConfigureServiceProvider() @@ -246,7 +297,10 @@ private static void ConfigureServiceProvider() var settingsManager = Services.GetRequiredService(); - settingsManager.LibraryDirOverride = Program.Args.DataDirectoryOverride; + if (Program.Args.DataDirectoryOverride is not null) + { + settingsManager.SetLibraryDirOverride(Program.Args.DataDirectoryOverride); + } if (settingsManager.TryFindLibrary()) { @@ -276,7 +330,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services) { provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService() @@ -297,7 +351,9 @@ internal static void ConfigureDialogViewModels(IServiceCollection services, Type 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 +378,7 @@ internal static IServiceCollection ConfigureServices() services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix"); var exportedTypes = AppDomain - .CurrentDomain - .GetAssemblies() + .CurrentDomain.GetAssemblies() .Where(a => a.FullName?.StartsWith("StabilityMatrix") == true) .SelectMany(a => a.GetExportedTypes()) .ToArray(); @@ -332,7 +387,8 @@ internal static IServiceCollection ConfigureServices() .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 +408,12 @@ internal static IServiceCollection ConfigureServices() .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 +445,9 @@ internal static IServiceCollection ConfigureServices() // Rich presence services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton( + provider => provider.GetRequiredService() + ); Config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) @@ -512,7 +573,7 @@ internal static IServiceCollection ConfigureServices() .AddRefitClient(defaultRefitSettings) .ConfigureHttpClient(c => { - c.BaseAddress = new Uri("https://stableauthentication.azurewebsites.net"); + c.BaseAddress = new Uri("https://auth.lykos.ai"); c.Timeout = TimeSpan.FromSeconds(15); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) @@ -557,7 +618,11 @@ internal static IServiceCollection ConfigureServices() #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 +642,79 @@ public static void Shutdown(int exitCode = 0) { 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; + + // Cancel shutdown for now + e.Cancel = true; + isAsyncDisposeComplete = true; - Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables"); + Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables"); - Task.Run(async () => + Dispatcher + .UIThread.InvokeAsync(async () => + { + foreach (var disposable in asyncDisposables) { - foreach (var disposable in asyncDisposables) + Debug.WriteLine($"Disposing IAsyncDisposable ({disposable.GetType().Name})"); + try { - Debug.WriteLine($"Disposing IAsyncDisposable ({disposable.GetType().Name})"); - try - { - await disposable.DisposeAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Debug.Fail(ex.ToString()); - } + await disposable.DisposeAsync().ConfigureAwait(false); } - }) - .ContinueWith(_ => + catch (Exception ex) + { + Debug.Fail(ex.ToString()); + } + } + }) + .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 +767,12 @@ private static LoggingConfiguration ConfigureLogging() .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 +785,9 @@ private static LoggingConfiguration ConfigureLogging() 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 +808,18 @@ private static LoggingConfiguration ConfigureLogging() // 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 +858,36 @@ internal static void DebugSaveScreenshot(int dpi = 96) 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 68f768844..5a51640e0 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 000000000..079c0b1f0 Binary files /dev/null and b/StabilityMatrix.Avalonia/Assets/AppIcon.icns differ diff --git a/StabilityMatrix.Avalonia/Collections/SearchCollection.cs b/StabilityMatrix.Avalonia/Collections/SearchCollection.cs new file mode 100644 index 000000000..6a268ec2f --- /dev/null +++ b/StabilityMatrix.Avalonia/Collections/SearchCollection.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData; +using DynamicData.Binding; +using JetBrains.Annotations; + +namespace StabilityMatrix.Avalonia.Collections; + +[PublicAPI] +public class SearchCollection : AbstractNotifyPropertyChanged, IDisposable + where TObject : notnull + where TKey : notnull +{ + private readonly IDisposable cleanUp; + + private Func>? PredicateSelector { get; } + private Func>? ScorerSelector { get; } + private Func? Scorer { get; set; } + + private TQuery? _query; + public TQuery? Query + { + get => _query; + set => SetAndRaise(ref _query, value); + } + + private SortExpressionComparer _sortComparer = []; + public SortExpressionComparer SortComparer + { + get => _sortComparer; + set => SetAndRaise(ref _sortComparer, value); + } + + /// + /// Converts to . + /// + private SortExpressionComparer SearchItemSortComparer => + [ + ..SortComparer + .Select(sortExpression => new SortExpression( + item => sortExpression.Expression(item.Item), + sortExpression.Direction + )).Prepend(new SortExpression(item => item.Score, SortDirection.Descending)) + ]; + + public IObservableCollection Items { get; } = new ObservableCollectionExtended(); + + public IObservableCollection FilteredItems { get; } = + new ObservableCollectionExtended(); + + public SearchCollection( + IObservable> source, + Func> predicateSelector, + SortExpressionComparer? sortComparer = null + ) + { + PredicateSelector = predicateSelector; + + if (sortComparer is not null) + { + SortComparer = sortComparer; + } + + // Observable which creates a new predicate whenever Query property changes + var dynamicPredicate = this.WhenValueChanged(@this => @this.Query).Select(predicateSelector); + + cleanUp = source + .Bind(Items) + .Filter(dynamicPredicate) + .Sort(SortComparer) + .Bind(FilteredItems) + .Subscribe(); + } + + public SearchCollection( + IObservable> source, + Func> scorerSelector, + SortExpressionComparer? sortComparer = null + ) + { + ScorerSelector = scorerSelector; + + if (sortComparer is not null) + { + SortComparer = sortComparer; + } + + // Monitor Query property for changes + var queryChanged = this.WhenValueChanged(@this => @this.Query).Select(_ => Unit.Default); + + cleanUp = new CompositeDisposable( + // Update Scorer property whenever Query property changes + queryChanged.Subscribe(_ => Scorer = scorerSelector(Query)), + // Transform source items into SearchItems + source + .Transform( + obj => + { + var (isMatch, score) = Scorer?.Invoke(obj) ?? (true, 0); + + return new SearchItem + { + Item = obj, + IsMatch = isMatch, + Score = score + }; + }, + forceTransform: queryChanged + ) + .Filter(item => item.IsMatch) + .Sort(SearchItemSortComparer, SortOptimisations.ComparesImmutableValuesOnly) + .Transform(searchItem => searchItem.Item) + .Bind(FilteredItems) + .Subscribe() + ); + } + + /// + /// Clears property by setting it to default value. + /// + public void ClearQuery() + { + Query = default; + } + + public void Dispose() + { + cleanUp.Dispose(); + GC.SuppressFinalize(this); + } + + private readonly record struct SearchItem + { + public TObject Item { get; init; } + public int Score { get; init; } + public bool IsMatch { get; init; } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml index eb681d53c..4728d0932 100644 --- a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml +++ b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml @@ -8,30 +8,53 @@ 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 000000000..c4cb6d568 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs @@ -0,0 +1,38 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; + +namespace StabilityMatrix.Avalonia.Controls; + +public class BetterComboBox : ComboBox +{ + 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; + 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/BetterContentDialog.cs b/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs index 4a27f4677..a0c279882 100644 --- a/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs +++ b/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs @@ -122,8 +122,10 @@ public ScrollBarVisibility ContentVerticalScrollBarVisibility set => SetValue(ContentVerticalScrollBarVisibilityProperty, value); } - public static readonly StyledProperty MinDialogWidthProperty = - AvaloniaProperty.Register("MinDialogWidth"); + public static readonly StyledProperty MinDialogWidthProperty = AvaloniaProperty.Register< + BetterContentDialog, + double + >("MinDialogWidth"); public double MinDialogWidth { @@ -131,8 +133,10 @@ public double MinDialogWidth set => SetValue(MinDialogWidthProperty, value); } - public static readonly StyledProperty MaxDialogWidthProperty = - AvaloniaProperty.Register("MaxDialogWidth"); + public static readonly StyledProperty MaxDialogWidthProperty = AvaloniaProperty.Register< + BetterContentDialog, + double + >("MaxDialogWidth"); public double MaxDialogWidth { @@ -140,8 +144,21 @@ public double MaxDialogWidth set => SetValue(MaxDialogWidthProperty, value); } - public static readonly StyledProperty MaxDialogHeightProperty = - AvaloniaProperty.Register("MaxDialogHeight"); + public static readonly StyledProperty MinDialogHeightProperty = AvaloniaProperty.Register< + BetterContentDialog, + double + >("MinDialogHeight"); + + public double MinDialogHeight + { + get => GetValue(MaxDialogHeightProperty); + set => SetValue(MaxDialogHeightProperty, value); + } + + public static readonly StyledProperty MaxDialogHeightProperty = AvaloniaProperty.Register< + BetterContentDialog, + double + >("MaxDialogHeight"); public double MaxDialogHeight { @@ -149,8 +166,10 @@ public double MaxDialogHeight set => SetValue(MaxDialogHeightProperty, value); } - public static readonly StyledProperty ContentMarginProperty = - AvaloniaProperty.Register("ContentMargin"); + public static readonly StyledProperty ContentMarginProperty = AvaloniaProperty.Register< + BetterContentDialog, + Thickness + >("ContentMargin"); public Thickness ContentMargin { @@ -158,8 +177,10 @@ public Thickness ContentMargin set => SetValue(ContentMarginProperty, value); } - public static readonly StyledProperty CloseOnClickOutsideProperty = - AvaloniaProperty.Register("CloseOnClickOutside"); + public static readonly StyledProperty CloseOnClickOutsideProperty = AvaloniaProperty.Register< + BetterContentDialog, + bool + >("CloseOnClickOutside"); /// /// Whether to close the dialog when clicking outside of it (on the blurred background) @@ -187,12 +208,17 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var point = e.GetPosition(this); - if ( - !backgroundPart.Bounds.Contains(point) - && (Content as Control)?.DataContext is ContentDialogViewModelBase vm - ) + if (!backgroundPart.Bounds.Contains(point)) { - vm.OnCloseButtonClick(); + // Use vm if available + if ((Content as Control)?.DataContext is ContentDialogViewModelBase vm) + { + vm.OnCloseButtonClick(); + } + else + { + Hide(ContentDialogResult.None); + } } } } @@ -211,10 +237,7 @@ private void TryBindButtons() viewModelDirect.SecondaryButtonClick += OnDialogButtonClick; viewModelDirect.CloseButtonClick += OnDialogButtonClick; } - else if ( - (Content as Control)?.DataContext - is ContentDialogProgressViewModelBase progressViewModel - ) + else if ((Content as Control)?.DataContext is ContentDialogProgressViewModelBase progressViewModel) { progressViewModel.PrimaryButtonClick += OnDialogButtonClick; progressViewModel.SecondaryButtonClick += OnDialogButtonClick; @@ -234,8 +257,7 @@ is ContentDialogProgressViewModelBase progressViewModel } else { - PrimaryButton.IsVisible = - IsPrimaryButtonEnabled && !string.IsNullOrEmpty(PrimaryButtonText); + PrimaryButton.IsVisible = IsPrimaryButtonEnabled && !string.IsNullOrEmpty(PrimaryButtonText); } } diff --git a/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs b/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs new file mode 100644 index 000000000..4e9ac24fb --- /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/HyperlinkIconButton.cs b/StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs index 8535ba31f..5525dae3a 100644 --- a/StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs +++ b/StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs @@ -1,13 +1,122 @@ using System; +using System.Diagnostics; +using System.IO; +using AsyncAwaitBestPractices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Logging; using FluentAvalonia.UI.Controls; +using StabilityMatrix.Core.Processes; +using Symbol = FluentIcons.Common.Symbol; namespace StabilityMatrix.Avalonia.Controls; /// /// Like , but with a link icon left of the text content. /// -public class HyperlinkIconButton : HyperlinkButton +public class HyperlinkIconButton : Button { - /// + private Uri? _navigateUri; + + /// + /// Defines the property + /// + public static readonly DirectProperty NavigateUriProperty = + AvaloniaProperty.RegisterDirect( + nameof(NavigateUri), + x => x.NavigateUri, + (x, v) => x.NavigateUri = v + ); + + /// + /// Gets or sets the Uri that the button should navigate to upon clicking. In assembly paths are not supported, (e.g., avares://...) + /// + public Uri? NavigateUri + { + get => _navigateUri; + set => SetAndRaise(NavigateUriProperty, ref _navigateUri, value); + } + + public static readonly StyledProperty IconProperty = AvaloniaProperty.Register< + HyperlinkIconButton, + Symbol + >("Icon", Symbol.Link); + + public Symbol Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + protected override Type StyleKeyOverride => typeof(HyperlinkIconButton); + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + // Update icon + if (change.Property == NavigateUriProperty) + { + var uri = change.GetNewValue(); + + if (uri is not null && uri.IsFile && Icon == Symbol.Link) + { + Icon = Symbol.Open; + } + } + } + + protected override void OnClick() + { + base.OnClick(); + + if (NavigateUri is null) + return; + + // File or Folder URIs + if (NavigateUri.IsFile) + { + var path = NavigateUri.LocalPath; + + if (Directory.Exists(path)) + { + ProcessRunner + .OpenFolderBrowser(path) + .SafeFireAndForget(ex => + { + Logger.TryGet(LogEventLevel.Error, $"Unable to open directory Uri {NavigateUri}"); + }); + } + else if (File.Exists(path)) + { + ProcessRunner + .OpenFileBrowser(path) + .SafeFireAndForget(ex => + { + Logger.TryGet(LogEventLevel.Error, $"Unable to open file Uri {NavigateUri}"); + }); + } + } + // Web + else + { + try + { + Process.Start( + new ProcessStartInfo(NavigateUri.ToString()) { UseShellExecute = true, Verb = "open" } + ); + } + catch + { + Logger.TryGet(LogEventLevel.Error, $"Unable to open Uri {NavigateUri}"); + } + } + } + + protected override bool RegisterContentPresenter(ContentPresenter presenter) + { + return presenter.Name == "ContentPresenter"; + } } diff --git a/StabilityMatrix.Avalonia/Controls/Inference/ControlNetCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/ControlNetCard.axaml index 29ccfdd84..b43ea05b0 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/ControlNetCard.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/ControlNetCard.axaml @@ -2,7 +2,7 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:StabilityMatrix.Avalonia.Controls" - xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" + xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" diff --git a/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml index e0fc6a1c0..a8b5c9418 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/Inference/SelectImageCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml index 6b480ab85..f3e3b5f89 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml @@ -3,7 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:StabilityMatrix.Avalonia.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" + xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:mocks="using:StabilityMatrix.Avalonia.DesignData" xmlns:ui="using:FluentAvalonia.UI.Controls" diff --git a/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml index 95f96b76a..e931a603a 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml @@ -4,7 +4,7 @@ xmlns:local="clr-namespace:StabilityMatrix.Avalonia" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:vmInference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference" - xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" + xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:modules="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference.Modules" xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" diff --git a/StabilityMatrix.Avalonia/Controls/Inference/StackExpander.axaml b/StabilityMatrix.Avalonia/Controls/Inference/StackExpander.axaml index 19af6987c..66eda5f0a 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/StackExpander.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/StackExpander.axaml @@ -2,11 +2,11 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:StabilityMatrix.Avalonia.Controls" - xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" xmlns:local="clr-namespace:StabilityMatrix.Avalonia" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:vmInference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference" + xmlns:fluent="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" x:DataType="vmInference:StackExpanderViewModel"> @@ -66,7 +66,7 @@ Classes="transparent-full" Command="{Binding SettingsCommand}" IsVisible="{Binding IsSettingsEnabled}"> - + diff --git a/StabilityMatrix.Avalonia/Controls/Inference/UpscalerCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/UpscalerCard.axaml index c239f9e86..a71c8cdc5 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/UpscalerCard.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/UpscalerCard.axaml @@ -8,7 +8,7 @@ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:vmInference="using:StabilityMatrix.Avalonia.ViewModels.Inference" xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" - xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" + xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" x:DataType="vmInference:UpscalerCardViewModel"> diff --git a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml index c6e7f3e1f..5f156f844 100644 --- a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml +++ b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml @@ -1,8 +1,6 @@  + xmlns:controls="using:StabilityMatrix.Avalonia.Controls"> diff --git a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs index 2c91651ec..af905ecf8 100644 --- a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs +++ b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs @@ -3,17 +3,12 @@ using System.Linq; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Documents; using Avalonia.Controls.Primitives; -using Avalonia.Data; using Avalonia.Layout; -using Avalonia.Markup.Xaml.MarkupExtensions; using Avalonia.Media; using Avalonia.VisualTree; +using FluentIcons.Avalonia.Fluent; using FluentIcons.Common; -using FluentIcons.FluentAvalonia; -using SpacedGridControl.Avalonia; -using StabilityMatrix.Avalonia.Styles; namespace StabilityMatrix.Avalonia.Controls; @@ -36,10 +31,10 @@ public bool IsEditable set => SetValue(IsEditableProperty, value); } - public static readonly StyledProperty MaximumProperty = AvaloniaProperty.Register< - StarsRating, - int - >(nameof(Maximum), 5); + public static readonly StyledProperty MaximumProperty = AvaloniaProperty.Register( + nameof(Maximum), + 5 + ); public int Maximum { diff --git a/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml new file mode 100644 index 000000000..f9f38ba9b --- /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 000000000..703475126 --- /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 000000000..c247611ae --- /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 000000000..a730a993b --- /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/Converters/EnumAttributeConverter.cs b/StabilityMatrix.Avalonia/Converters/EnumAttributeConverter.cs new file mode 100644 index 000000000..a107f714a --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/EnumAttributeConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Avalonia.Data.Converters; + +namespace StabilityMatrix.Avalonia.Converters; + +/// +/// Converts an enum value to an attribute +/// +/// Type of attribute +public class EnumAttributeConverter(Func? accessor = null) : IValueConverter + where TAttribute : Attribute +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is null) + return null; + + if (value is not Enum @enum) + throw new ArgumentException("Value must be an enum"); + + var field = @enum.GetType().GetField(@enum.ToString()); + if (field is null) + throw new ArgumentException("Value must be an enum"); + + if (field.GetCustomAttributes().FirstOrDefault() is not { } attribute) + throw new ArgumentException($"Enum value {@enum} does not have attribute {typeof(TAttribute)}"); + + return accessor is not null ? accessor(attribute) : attribute; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/StabilityMatrix.Avalonia/Converters/EnumAttributeConverters.cs b/StabilityMatrix.Avalonia/Converters/EnumAttributeConverters.cs new file mode 100644 index 000000000..352360a72 --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/EnumAttributeConverters.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace StabilityMatrix.Avalonia.Converters; + +internal static class EnumAttributeConverters +{ + public static EnumAttributeConverter Display => new(); + + public static EnumAttributeConverter DisplayName => new(attribute => attribute.Name); + + public static EnumAttributeConverter DisplayDescription => + new(attribute => attribute.Description); +} diff --git a/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs b/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs new file mode 100644 index 000000000..b24d07d96 --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs @@ -0,0 +1,36 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Avalonia.Converters; + +public class FileUriConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (targetType != typeof(Uri)) + { + return null; + } + + return value switch + { + string str => new Uri("file://" + str), + IFormattable formattable => new Uri("file://" + formattable.ToString(null, culture)), + _ => null + }; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (targetType == typeof(string) && value is Uri uri) + { + return uri.ToString().StripStart("file://"); + } + + return null; + } +} diff --git a/StabilityMatrix.Avalonia/Converters/FuncCommandConverter.cs b/StabilityMatrix.Avalonia/Converters/FuncCommandConverter.cs new file mode 100644 index 000000000..e55add56a --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/FuncCommandConverter.cs @@ -0,0 +1,53 @@ +using System; +using System.Globalization; +using System.Windows.Input; +using Avalonia.Data.Converters; +using PropertyModels.ComponentModel; + +namespace StabilityMatrix.Avalonia.Converters; + +/// +/// Converts an object's named to a . +/// +public class FuncCommandConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is null || parameter is null) + { + return null; + } + + // Parameter is the name of the Func to convert. + if (parameter is not string funcName) + { + // ReSharper disable once LocalizableElement + throw new ArgumentException("Parameter must be a string.", nameof(parameter)); + } + + // Find the Func on the object. + if (value.GetType().GetMethod(funcName) is not { } methodInfo) + { + // ReSharper disable once LocalizableElement + throw new ArgumentException( + $"Method {funcName} not found on {value.GetType().Name}.", + nameof(parameter) + ); + } + + // Create a delegate from the method info. + var func = (Action)methodInfo.CreateDelegate(typeof(Action), value); + + // Create ICommand + var command = ReactiveCommand.Create(func); + + return command; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index a1bd20222..3f2476c9f 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -4,15 +4,14 @@ 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.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; @@ -37,8 +37,10 @@ using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.Comfy; using StabilityMatrix.Core.Models.Database; +using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Models.Packages.Extensions; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Update; using StabilityMatrix.Core.Python; @@ -175,12 +177,23 @@ public static void Initialize() }; 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, @@ -198,6 +211,7 @@ public static void Initialize() PreviewImagePath = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/" + "78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg", + UpdateAvailable = true, ConnectedModel = new ConnectedModelInfo { VersionName = "Lightning Auroral", @@ -373,25 +387,27 @@ public static void Initialize() 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 +418,10 @@ public static void Initialize() } [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,16 +432,64 @@ public static void Initialize() 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(); + public static NewOneClickInstallViewModel NewOneClickInstallViewModel => + Services.GetRequiredService(); + + public static RecommendedModelsViewModel RecommendedModelsViewModel => + DialogFactory.Get(vm => + { + vm.Sd15Models = new ObservableCollectionExtended() + { + new() + { + ModelVersion = new CivitModelVersion + { + Name = "BB95 Furry Mix", + Description = "A furry mix of BB95", + Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 }, + Images = + [ + new CivitImage + { + Url = + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg" + } + ], + }, + Author = "bb95" + }, + new() + { + ModelVersion = new CivitModelVersion + { + Name = "BB95 Furry Mix", + Description = "A furry mix of BB95", + Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 }, + Images = + [ + new CivitImage + { + Url = + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg" + } + ], + }, + Author = "bb95" + } + }; + }); public static OutputsPageViewModel OutputsPageViewModel { get @@ -452,7 +519,10 @@ public static PackageManagerViewModel PackageManagerViewModel 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; @@ -461,6 +531,39 @@ public static PackageManagerViewModel PackageManagerViewModel } } + public static PackageExtensionBrowserViewModel PackageExtensionBrowserViewModel => + DialogFactory.Get(vm => + { + vm.AddExtensions( + [ + new PackageExtension + { + Author = "123", + Title = "Cool Extension", + Description = "This is an interesting extension", + Reference = new Uri("https://github.com/LykosAI/StabilityMatrix"), + Files = [new Uri("https://github.com/LykosAI/StabilityMatrix")] + }, + new PackageExtension + { + Author = "123", + Title = "Cool Extension", + Description = "This is an interesting extension", + Reference = new Uri("https://github.com/LykosAI/StabilityMatrix"), + Files = [new Uri("https://github.com/LykosAI/StabilityMatrix")] + } + ], + [ + new InstalledPackageExtension + { + GitRepositoryUrl = "https://github.com/LykosAI/StabilityMatrix", + Paths = [new DirectoryPath("example-dir")] + }, + new InstalledPackageExtension { Paths = [new DirectoryPath("example-dir-2")] } + ] + ); + }); + public static CheckpointsPageViewModel CheckpointsPageViewModel => Services.GetRequiredService(); @@ -469,14 +572,21 @@ public static PackageManagerViewModel PackageManagerViewModel 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(); + public static NotificationSettingsViewModel NotificationSettingsViewModel => + Services.GetRequiredService(); + public static UpdateSettingsViewModel UpdateSettingsViewModel { get @@ -556,7 +666,10 @@ public static UpdateSettingsViewModel UpdateSettingsViewModel } } }; - 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 +743,22 @@ public static UpdateSettingsViewModel UpdateSettingsViewModel 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 +774,8 @@ public static UpdateSettingsViewModel UpdateSettingsViewModel }); public static SeedCardViewModel SeedCardViewModel => new(); + public static SvdImgToVidConditioningViewModel SvdImgToVidConditioningViewModel => new(); + public static VideoOutputSettingsCardViewModel VideoOutputSettingsCardViewModel => new(); public static SamplerCardViewModel SamplerCardViewModel => DialogFactory.Get(vm => @@ -681,6 +805,9 @@ public static UpdateSettingsViewModel UpdateSettingsViewModel public static ModelCardViewModel ModelCardViewModel => DialogFactory.Get(); + public static ImgToVidModelCardViewModel ImgToVidModelCardViewModel => + DialogFactory.Get(); + public static ImageGalleryCardViewModel ImageGalleryCardViewModel => DialogFactory.Get(vm => { @@ -706,7 +833,8 @@ public static UpdateSettingsViewModel UpdateSettingsViewModel ); }); - public static ImageFolderCardViewModel ImageFolderCardViewModel => DialogFactory.Get(); + public static ImageFolderCardViewModel ImageFolderCardViewModel => + DialogFactory.Get(); public static FreeUCardViewModel FreeUCardViewModel => DialogFactory.Get(); @@ -759,7 +887,8 @@ public static UpdateSettingsViewModel UpdateSettingsViewModel 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 +919,42 @@ public static CompletionList SampleCompletionList } } + 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 +981,8 @@ public static CompletionList SampleCompletionList 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 +995,17 @@ public static CompletionList SampleCompletionList }); 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 +1015,8 @@ public object? this[string typeName] { 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 3081441ca..49458c6da 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,26 @@ public IEnumerable GetFromModelIndex(SharedFolderType types) } /// - 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); + } + + public Task CheckModelsForUpdates() + { + return Task.CompletedTask; } /// diff --git a/StabilityMatrix.Avalonia/DialogHelper.cs b/StabilityMatrix.Avalonia/DialogHelper.cs index 0cdfeabb7..a2438a204 100644 --- a/StabilityMatrix.Avalonia/DialogHelper.cs +++ b/StabilityMatrix.Avalonia/DialogHelper.cs @@ -23,15 +23,15 @@ 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 BetterContentDialog CreateTextEntryDialog( IReadOnlyList textFields ) { - return CreateTextEntryDialog( - title, - new MarkdownScrollViewer { Markdown = description }, - textFields - ); + return CreateTextEntryDialog(title, new MarkdownScrollViewer { Markdown = description }, textFields); } /// @@ -80,11 +76,7 @@ IReadOnlyList textFields 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 @@ IReadOnlyList textFields 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 BetterContentDialog CreateMarkdownDialog( 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 BetterContentDialog CreateApiExceptionDialog( 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 BetterContentDialog CreateJsonDialog( 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 BetterContentDialog CreatePromptErrorDialog( { 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; @@ -526,7 +509,7 @@ public static BetterContentDialog CreatePromptErrorDialog( models.Select(m => m.FileNameWithoutExtension) ); - if (result.Score > 40) + if (result is { Score: > 40 }) { mainGrid.Children.Add( new InfoBar diff --git a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs b/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs index 38c28fb32..a4a88705f 100644 --- a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs +++ b/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs @@ -20,15 +20,17 @@ public static void SetupEmptyLatentSource( 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 void SetupEmptyLatentSource( // 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 void SetupImagePrimarySource( 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 void SetupImagePrimarySource( // 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 string SetupOutputImage(this ComfyNodeBuilder builder) 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/Extensions/NotificationLevelExtensions.cs b/StabilityMatrix.Avalonia/Extensions/NotificationLevelExtensions.cs new file mode 100644 index 000000000..fbb62e5a5 --- /dev/null +++ b/StabilityMatrix.Avalonia/Extensions/NotificationLevelExtensions.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia.Controls.Notifications; +using StabilityMatrix.Core.Models.Settings; + +namespace StabilityMatrix.Avalonia.Extensions; + +public static class NotificationLevelExtensions +{ + public static NotificationType ToNotificationType(this NotificationLevel level) + { + return level switch + { + NotificationLevel.Information => NotificationType.Information, + NotificationLevel.Success => NotificationType.Success, + NotificationLevel.Warning => NotificationType.Warning, + NotificationLevel.Error => NotificationType.Error, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) + }; + } +} diff --git a/StabilityMatrix.Avalonia/Extensions/NotificationServiceExtensions.cs b/StabilityMatrix.Avalonia/Extensions/NotificationServiceExtensions.cs new file mode 100644 index 000000000..d895382f0 --- /dev/null +++ b/StabilityMatrix.Avalonia/Extensions/NotificationServiceExtensions.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using DesktopNotifications; +using NLog; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Settings; + +namespace StabilityMatrix.Avalonia.Extensions; + +public static class NotificationServiceExtensions +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public static void OnPackageInstallCompleted( + this INotificationService notificationService, + IPackageModificationRunner runner + ) + { + OnPackageInstallCompletedAsync(notificationService, runner) + .SafeFireAndForget(ex => Logger.Error(ex, "Error Showing Notification")); + } + + private static async Task OnPackageInstallCompletedAsync( + this INotificationService notificationService, + IPackageModificationRunner runner + ) + { + if (runner.Failed) + { + Logger.Error(runner.Exception, "Error Installing Package"); + + await notificationService.ShowAsync( + NotificationKey.Package_Install_Failed, + new Notification + { + Title = runner.ModificationFailedTitle, + Body = runner.ModificationFailedMessage + } + ); + } + else + { + await notificationService.ShowAsync( + NotificationKey.Package_Install_Completed, + new Notification + { + Title = runner.ModificationCompleteTitle, + Body = runner.ModificationCompleteMessage + } + ); + } + } +} diff --git a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs index fb3943356..2ee7f8112 100644 --- a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Versioning; @@ -10,7 +11,9 @@ using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Python; @@ -61,6 +64,55 @@ private async Task CheckIsGitInstalled() return isGitInstalled == true; } + public Task InstallPackageRequirements(BasePackage package, IProgress? progress = null) => + InstallPackageRequirements(package.Prerequisites.ToList(), progress); + + public async Task InstallPackageRequirements( + List prerequisites, + IProgress? progress = null + ) + { + await UnpackResourcesIfNecessary(progress); + + if (prerequisites.Contains(PackagePrerequisite.Python310)) + { + await InstallPythonIfNecessary(progress); + await InstallVirtualenvIfNecessary(progress); + } + + if (prerequisites.Contains(PackagePrerequisite.Git)) + { + await InstallGitIfNecessary(progress); + } + + if (prerequisites.Contains(PackagePrerequisite.Node)) + { + await InstallNodeIfNecessary(progress); + } + } + + private async Task InstallVirtualenvIfNecessary(IProgress? progress = null) + { + // python stuff + if (!PyRunner.PipInstalled || !PyRunner.VenvInstalled) + { + progress?.Report( + new ProgressReport(-1f, "Installing Python prerequisites...", isIndeterminate: true) + ); + + await pyRunner.Initialize().ConfigureAwait(false); + + if (!PyRunner.PipInstalled) + { + await pyRunner.SetupPip().ConfigureAwait(false); + } + if (!PyRunner.VenvInstalled) + { + await pyRunner.InstallPackage("virtualenv").ConfigureAwait(false); + } + } + } + public async Task InstallAllIfNecessary(IProgress? progress = null) { await UnpackResourcesIfNecessary(progress); @@ -70,15 +122,9 @@ public async Task InstallAllIfNecessary(IProgress? progress = nu public async Task UnpackResourcesIfNecessary(IProgress? progress = null) { // Array of (asset_uri, extract_to) - var assets = new[] - { - (Assets.SevenZipExecutable, AssetsDir), - (Assets.SevenZipLicense, AssetsDir), - }; + var assets = new[] { (Assets.SevenZipExecutable, AssetsDir), (Assets.SevenZipLicense, AssetsDir), }; - progress?.Report( - new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true) - ); + progress?.Report(new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true)); Directory.CreateDirectory(AssetsDir); foreach (var (asset, extractDir) in assets) @@ -86,9 +132,7 @@ public async Task UnpackResourcesIfNecessary(IProgress? progress await asset.ExtractToDir(extractDir); } - progress?.Report( - new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false) - ); + progress?.Report(new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false)); } public async Task InstallGitIfNecessary(IProgress? progress = null) @@ -150,8 +194,7 @@ public async Task RunGit(ProcessArgs args, string? workingDirectory = null) if (result.ExitCode != 0) { Logger.Error( - "Git command [{Command}] failed with exit code " - + "{ExitCode}:\n{StdOut}\n{StdErr}", + "Git command [{Command}] failed with exit code " + "{ExitCode}:\n{StdOut}\n{StdErr}", command, result.ExitCode, result.StandardOutput, @@ -239,9 +282,9 @@ public async Task InstallPythonIfNecessary(IProgress? progress = progress?.Report(new ProgressReport(1, "Installing Python", isIndeterminate: false)); } - public Task GetGitOutput(string? workingDirectory = null, params string[] args) + public Task GetGitOutput(ProcessArgs args, string? workingDirectory = null) { - throw new NotImplementedException(); + return ProcessRunner.RunBashCommand(args.Prepend("git").ToArray(), workingDirectory ?? ""); } [SupportedOSPlatform("Linux")] @@ -249,7 +292,8 @@ public Task GetGitOutput(string? workingDirectory = null, params string[ public async Task RunNpm( ProcessArgs args, string? workingDirectory = null, - Action? onProcessOutput = null + Action? onProcessOutput = null, + IReadOnlyDictionary? envVars = null ) { var command = args.Prepend([NpmPath]); diff --git a/StabilityMatrix.Avalonia/Helpers/UriHandler.cs b/StabilityMatrix.Avalonia/Helpers/UriHandler.cs index 75dd4a5fd..3b16d078d 100644 --- a/StabilityMatrix.Avalonia/Helpers/UriHandler.cs +++ b/StabilityMatrix.Avalonia/Helpers/UriHandler.cs @@ -58,17 +58,19 @@ public void SendAndExit(Uri uri) 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 @@ private void RegisterUriSchemeWin() } } - 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/Helpers/WindowsPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs index 9bde32200..c3cc382f5 100644 --- a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs @@ -1,15 +1,20 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.Versioning; using System.Threading.Tasks; using Microsoft.Win32; using NLog; using Octokit; +using PropertyModels.Extensions; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.Helpers; @@ -22,6 +27,7 @@ public class WindowsPrerequisiteHelper : IPrerequisiteHelper private readonly IGitHubClient gitHubClient; private readonly IDownloadService downloadService; private readonly ISettingsManager settingsManager; + private readonly IPyRunner pyRunner; private const string VcRedistDownloadUrl = "https://aka.ms/vs/16/release/vc_redist.x64.exe"; private const string TkinterDownloadUrl = @@ -59,12 +65,14 @@ public class WindowsPrerequisiteHelper : IPrerequisiteHelper public WindowsPrerequisiteHelper( IGitHubClient gitHubClient, IDownloadService downloadService, - ISettingsManager settingsManager + ISettingsManager settingsManager, + IPyRunner pyRunner ) { this.gitHubClient = gitHubClient; this.downloadService = downloadService; this.settingsManager = settingsManager; + this.pyRunner = pyRunner; } public async Task RunGit( @@ -99,29 +107,28 @@ public async Task RunGit(ProcessArgs args, string? workingDirectory = null) result.EnsureSuccessExitCode(); } - public async Task GetGitOutput(string? workingDirectory = null, params string[] args) + public Task GetGitOutput(ProcessArgs args, string? workingDirectory = null) { - var process = await ProcessRunner.GetProcessOutputAsync( + return ProcessRunner.GetProcessResultAsync( GitExePath, - string.Join(" ", args), + args, workingDirectory: workingDirectory, environmentVariables: new Dictionary { { "PATH", Compat.GetEnvPathWithExtensions(GitBinPath) } } ); - - return process; } public async Task RunNpm( ProcessArgs args, string? workingDirectory = null, - Action? onProcessOutput = null + Action? onProcessOutput = null, + IReadOnlyDictionary? envVars = null ) { var result = await ProcessRunner - .GetProcessResultAsync(NodeExistsPath, args, workingDirectory) + .GetProcessResultAsync(NodeExistsPath, args, workingDirectory, envVars) .ConfigureAwait(false); result.EnsureSuccessExitCode(); @@ -129,6 +136,38 @@ public async Task RunNpm( onProcessOutput?.Invoke(ProcessOutput.FromStdErrLine(result.StandardError)); } + public Task InstallPackageRequirements(BasePackage package, IProgress? progress = null) => + InstallPackageRequirements(package.Prerequisites.ToList(), progress); + + public async Task InstallPackageRequirements( + List prerequisites, + IProgress? progress = null + ) + { + await UnpackResourcesIfNecessary(progress); + + if (prerequisites.Contains(PackagePrerequisite.Python310)) + { + await InstallPythonIfNecessary(progress); + await InstallVirtualenvIfNecessary(progress); + } + + if (prerequisites.Contains(PackagePrerequisite.Git)) + { + await InstallGitIfNecessary(progress); + } + + if (prerequisites.Contains(PackagePrerequisite.VcRedist)) + { + await InstallVcRedistIfNecessary(progress); + } + + if (prerequisites.Contains(PackagePrerequisite.Node)) + { + await InstallNodeIfNecessary(progress); + } + } + public async Task InstallAllIfNecessary(IProgress? progress = null) { await InstallVcRedistIfNecessary(progress); @@ -141,15 +180,9 @@ public async Task InstallAllIfNecessary(IProgress? progress = nu public async Task UnpackResourcesIfNecessary(IProgress? progress = null) { // Array of (asset_uri, extract_to) - var assets = new[] - { - (Assets.SevenZipExecutable, AssetsDir), - (Assets.SevenZipLicense, AssetsDir), - }; + var assets = new[] { (Assets.SevenZipExecutable, AssetsDir), (Assets.SevenZipLicense, AssetsDir), }; - progress?.Report( - new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true) - ); + progress?.Report(new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true)); Directory.CreateDirectory(AssetsDir); foreach (var (asset, extractDir) in assets) @@ -157,9 +190,7 @@ public async Task UnpackResourcesIfNecessary(IProgress? progress await asset.ExtractToDir(extractDir); } - progress?.Report( - new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false) - ); + progress?.Report(new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false)); } public async Task InstallPythonIfNecessary(IProgress? progress = null) @@ -275,17 +306,35 @@ public async Task InstallPythonIfNecessary(IProgress? progress = } } + private async Task InstallVirtualenvIfNecessary(IProgress? progress = null) + { + // python stuff + if (!PyRunner.PipInstalled || !PyRunner.VenvInstalled) + { + progress?.Report( + new ProgressReport(-1f, "Installing Python prerequisites...", isIndeterminate: true) + ); + + await pyRunner.Initialize().ConfigureAwait(false); + + if (!PyRunner.PipInstalled) + { + await pyRunner.SetupPip().ConfigureAwait(false); + } + if (!PyRunner.VenvInstalled) + { + await pyRunner.InstallPackage("virtualenv").ConfigureAwait(false); + } + } + } + [SupportedOSPlatform("windows")] public async Task InstallTkinterIfNecessary(IProgress? progress = null) { if (!Directory.Exists(TkinterExistsPath)) { Logger.Info("Downloading Tkinter"); - await downloadService.DownloadToFileAsync( - TkinterDownloadUrl, - TkinterZipPath, - progress: progress - ); + await downloadService.DownloadToFileAsync(TkinterDownloadUrl, TkinterZipPath, progress: progress); progress?.Report( new ProgressReport( progress: 1f, @@ -300,11 +349,7 @@ await downloadService.DownloadToFileAsync( } progress?.Report( - new ProgressReport( - progress: 1f, - message: "Tkinter install complete", - type: ProgressType.Generic - ) + new ProgressReport(progress: 1f, message: "Tkinter install complete", type: ProgressType.Generic) ); } @@ -338,10 +383,7 @@ await downloadService.DownloadToFileAsync( public async Task InstallVcRedistIfNecessary(IProgress? progress = null) { var registry = Registry.LocalMachine; - var key = registry.OpenSubKey( - @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64", - false - ); + var key = registry.OpenSubKey(@"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64", false); if (key != null) { var buildId = Convert.ToUInt32(key.GetValue("Bld")); @@ -375,10 +417,7 @@ await downloadService.DownloadToFileAsync( message: "Installing prerequisites..." ) ); - var process = ProcessRunner.StartAnsiProcess( - VcRedistDownloadPath, - "/install /quiet /norestart" - ); + var process = ProcessRunner.StartAnsiProcess(VcRedistDownloadPath, "/install /quiet /norestart"); await process.WaitForExitAsync(); progress?.Report( new ProgressReport( diff --git a/StabilityMatrix.Avalonia/Languages/Cultures.cs b/StabilityMatrix.Avalonia/Languages/Cultures.cs index 019aee8d6..11105cd15 100644 --- a/StabilityMatrix.Avalonia/Languages/Cultures.cs +++ b/StabilityMatrix.Avalonia/Languages/Cultures.cs @@ -13,7 +13,10 @@ public static class Cultures public static CultureInfo? Current => Resources.Culture; - public static readonly Dictionary SupportedCulturesByCode = new Dictionary + public static readonly Dictionary SupportedCulturesByCode = new Dictionary< + string, + CultureInfo + > { ["en-US"] = Default, ["ja-JP"] = new("ja-JP"), @@ -23,10 +26,13 @@ public static class Cultures ["fr-FR"] = new("fr-FR"), ["es"] = new("es"), ["ru-RU"] = new("ru-RU"), - ["tr-TR"] = new("tr-TR") + ["tr-TR"] = new("tr-TR"), + ["de"] = new("de"), + ["pt-PT"] = new("pt-PT") }; - public static IReadOnlyList SupportedCultures => SupportedCulturesByCode.Values.ToImmutableList(); + public static IReadOnlyList SupportedCultures => + SupportedCulturesByCode.Values.ToImmutableList(); public static CultureInfo GetSupportedCultureOrDefault(string? cultureCode) { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 4347bb9b7..2ddf7c05b 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -167,6 +167,15 @@ public static string Action_Copy { } } + /// + /// Looks up a localized string similar to Copy Details. + /// + public static string Action_CopyDetails { + get { + return ResourceManager.GetString("Action_CopyDetails", resourceCulture); + } + } + /// /// Looks up a localized string similar to Copy Trigger Words. /// @@ -203,6 +212,15 @@ public static string Action_Downgrade { } } + /// + /// Looks up a localized string similar to Download. + /// + public static string Action_Download { + get { + return ResourceManager.GetString("Action_Download", resourceCulture); + } + } + /// /// Looks up a localized string similar to Edit. /// @@ -221,6 +239,15 @@ public static string Action_ExitApplication { } } + /// + /// Looks up a localized string similar to Hide. + /// + public static string Action_Hide { + get { + return ResourceManager.GetString("Action_Hide", resourceCulture); + } + } + /// /// Looks up a localized string similar to Import. /// @@ -707,6 +734,15 @@ public static string Label_AreYouSure { } } + /// + /// 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 +869,15 @@ public static string Label_CivitAiLoginRequired { } } + /// + /// 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. /// @@ -1166,6 +1211,15 @@ public static string Label_FindConnectedMetadata { } } + /// + /// Looks up a localized string similar to Find in Model Browser. + /// + public static string Label_FindInModelBrowser { + get { + return ResourceManager.GetString("Label_FindInModelBrowser", resourceCulture); + } + } + /// /// Looks up a localized string similar to First Page. /// @@ -1184,6 +1238,24 @@ public static string Label_Folder { } } + /// + /// 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 +1301,15 @@ public static string Label_ImageToImage { } } + /// + /// 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. /// @@ -1328,6 +1409,15 @@ public static string Label_InstallationWithThisNameExists { } } + /// + /// Looks up a localized string similar to Installed. + /// + public static string Label_Installed { + get { + return ResourceManager.GetString("Label_Installed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Installing. /// @@ -1427,6 +1517,24 @@ public static string Label_LocalModel { } } + /// + /// 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 +1589,15 @@ public static string Label_ModelType { } } + /// + /// 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). /// @@ -1526,6 +1643,33 @@ public static string Label_No { } } + /// + /// Looks up a localized string similar to No extensions found.. + /// + public static string Label_NoExtensionsFound { + get { + return ResourceManager.GetString("Label_NoExtensionsFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to . + /// + public static string Label_NotificationOption_None { + get { + return ResourceManager.GetString("Label_NotificationOption_None", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Notifications. + /// + public static string Label_Notifications { + get { + return ResourceManager.GetString("Label_Notifications", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} images selected. /// @@ -1769,6 +1913,24 @@ public static string Label_ReadAndAgree { } } + /// + /// Looks up a localized string similar to Recommended Models. + /// + public static string Label_RecommendedModels { + get { + return ResourceManager.GetString("Label_RecommendedModels", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to While your package is installing, here are some models we recommend to help you get started.. + /// + public static string Label_RecommendedModelsSubText { + get { + return ResourceManager.GetString("Label_RecommendedModelsSubText", resourceCulture); + } + } + /// /// Looks up a localized string similar to Refiner. /// @@ -2156,6 +2318,24 @@ public static string Label_VersionType { } } + /// + /// 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.... /// @@ -2291,6 +2471,15 @@ public static string TeachingTip_ClickLaunchToGetStarted { } } + /// + /// Looks up a localized string similar to Check the progress of your package installations and model downloads here.. + /// + public static string TeachingTip_DownloadsExplanation { + get { + return ResourceManager.GetString("TeachingTip_DownloadsExplanation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Additional folders such as IPAdapters and TextualInversions (embeddings) can be enabled here. /// @@ -2310,7 +2499,7 @@ public static string Text_AppWillRelaunchAfterUpdate { } /// - /// Looks up a localized string similar to Choose your preferred interface and click Install to get started. + /// Looks up a localized string similar to Choose your preferred interface to get started. /// public static string Text_OneClickInstaller_SubHeader { get { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.de.resx b/StabilityMatrix.Avalonia/Languages/Resources.de.resx new file mode 100644 index 000000000..1821cda1f --- /dev/null +++ b/StabilityMatrix.Avalonia/Languages/Resources.de.resx @@ -0,0 +1,933 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Starten + + + Beenden + + + Speichern + + + Abbrechen + + + Sprache + + + Ein Neustart ist nötig, um Sprachänderungen vorzunehmen + + + Neustarten + + + Später Neustarten + + + Neustart benötigt + + + Unbekanntes Paket + + + Importieren + + + Pakettyp + + + Version + + + Versionstyp + + + Veröffentlichungen + + + Zweige + + + Checkpoints hierherziehen zum importieren + + + Betonung + + + Negativ Betonung + + + Embeddings + + + Netzwerk (Lora / LyCORIS) + + + Kommentare + + + Pixel-Gitter bei hohen Zoomstufen anzeigen + + + Schritte + + + Schritte - Base + + + Schritte - Refiner + + + CFG Wert + + + Entrauschungsstärke + + + Breite + + + Höhe + + + Refiner + + + VAE + + + Modell + + + Verbinden + + + Verbindet... + + + Schließen + + + Warten auf die Verbindung... + + + Updates verfügbar + + + Werde ein Patreon + + + Trete dem Discord Server bei + + + Downloads + + + Installieren + + + Erstmalige Einstellung überspringen + + + Ein unerwarteter Fehler ist aufgetreten + + + Beende die Applikation + + + Anzeigename + + + Eine Installation mit diesem namen existiert bereits. + + + Bitte wähle einen anderen namen oder ändere den Installationsordner. + + + Erweiterte Optionen + + + Commit + + + Geteilte Modell Ordnerstrategie + + + PyTorch Version + + + Dialog nach Beendigung schließen + + + Daten Ordner + + + Hier werden die Applikationsdaten (Modell Checkpoints, Web UIs, etc.) installiert. + + + Bei der Verwendung eines FAT32- oder exFAT-Laufwerks können Fehler auftreten. Wählen Sie ein anderes Laufwerk, um ein reibungsloseres Arbeiten zu ermöglichen. + + + Tragbarer Modus + + + Im portablen Modus werden alle Daten und Einstellungen in demselben Verzeichnis wie die Anwendung gespeichert. Sie können die Anwendung mit ihrem 'Daten'-Ordner an einen anderen Ort oder auf einen anderen Computer verschieben. + + + Weiter + + + Vorheriges Bild + + + Nächstes Bild + + + Modellbeschreibung + + + Eine neue Version von Stability Matrix ist verfügbar! + + + Neueste Importieren - + + + Alle Versionen + + + Suche modelle, #tags, or @nutzer + + + Suchen + + + Sortieren + + + Periode + + + Modelltyp + + + Basis Modell + + + Zeige NSFW Inhalte + + + Daten bereitgestellt von CivitAI + + + Seite + + + Erste Seite + + + Vorherige Seite + + + Nächste Seite + + + Letzte Seite + + + Umbenennen + + + Löschen + + + Öffnen in CivitAI + + + Verbundenes Modell + + + Lokales Modell + + + Im Explorer anzeigen + + + Neu + + + Ordner + + + Datei zum Importieren hier ablegen + + + Importieren mit Metadaten + + + Suche nach verbundenen Metadaten bei neuen lokalen Importen + + + Indizierung... + + + Modellordner + + + Kategorien + + + Lass uns starten + + + Ich habe gelesen und akzeptiere die + + + Lizensbestimmungen. + + + Finde verbundene Metadaten + + + Zeige Modell Bilder + + + Aussehen + + + Thema + + + Checkpoint Manager + + + Symbolische Links auf gemeinsame Checkpoints beim Herunterfahren entfernen + + + Wählen Sie diese Option, wenn Sie Probleme beim Verschieben von Stability Matrix auf ein anderes Laufwerk haben + + + Checkpoint-Cache zurücksetzen + + + Stellt den installierten Checkpoint-Cache wieder her. Wird verwendet, wenn Prüfpunkte im Modell-Browser falsch beschriftet sind + + + Paket Umgebung + + + Bearbeiten + + + Umgebungsvariablen + + + Eingebettetes Python + + + Version überprüfen + + + Integrationen + + + Discord Rich Presence + + + System + + + Füge Stability Matrix zum Startmenü hinzu + + + Verwendet den aktuellen Standort der Anwendung, Sie können dies erneut ausführen, wenn Sie die Anwendung verschieben + + + Nur auf Windows verfügbar + + + Hinzufügen für aktuellen nutzer + + + Hinzufügen für alle Nutzer + + + Wähle einen neuen Daten Ordner aus + + + Verschiebt keine existierenden Daten + + + Ordner auswählen + + + Über + + + Stability Matrix + + + Lizenz und Open Source Notizen + + + Klicken Sie auf Start, um loszulegen! + + + Stopp + + + Eingaben senden + + + Eingaben + + + Senden + + + Eingaben benötigt + + + Bestätigen? + + + Ja + + + Nein + + + Web UI öffnen + + + Willkommen zu Stability Matrix! + + + Wählen Sie Ihre bevorzugte Schnittstelle und klicken Sie auf Installieren, um loszulegen. + + + Installiert + + + Weiter zur Seite Start + + + Herunterladen des Pakets... + + + Herunterladen abgeschlossen + + + Installation abgeschlossen + + + Voraussetzungen installieren... + + + Paket-Voraussetzungen installieren... + + + Öffnen im Explorer + + + Öffnen in Finder + + + Deinstallieren + + + Auf Updates überprüfen + + + Update + + + Paket hinzufügen + + + Füge ein Paket hinzu, um loszulegen! + + + Name + + + Wert + + + Entfernen + + + Details + + + Callstack + + + Inner Exception + + + Suchen... + + + OK + + + Erneut versuchen + + + Python Versionsinfo + + + Neustarten + + + Löschen bestätigen + + + Dadurch werden der Paketordner und sein gesamter Inhalt gelöscht, einschließlich aller generierten Bilder und Dateien, die Sie hinzugefügt haben. + + + Paket deinstallieren... + + + Paket deinstalliert + + + Einige Dateien konnten nicht gelöscht werden. Bitte schließen Sie alle offenen Dateien im Paketverzeichnis und versuchen Sie es erneut. + + + Ungültiger Pakettyp + + + Aktualisiert {0} + + + Aktualisierung abgeschlossen + + + {0} wurde auf die neueste Version aktualisiert + + + Fehler beim Aktualisieren {0} + + + Aktualisierung fehlgeschlagen + + + Öffnen im Browser + + + Fehler bei der Installation des Pakets + + + Zweig + + + Automatisch zum Ende der Konsolenausgabe blättern + + + Lizenz + + + Modell-Sharing + + + Bitte wählen Sie ein Datenverzeichnis + + + Name des Datenordners + + + Aktuelles Verzeichnis: + + + Die App wird nach der Aktualisierung neu gestartet + + + Erinnern Sie mich später + + + Jetzt installieren + + + Anmerkungen zur Veröffentlichung + + + Projekt öffnen... + + + Speichern als... + + + Standard-Layout wiederherstellen + + + Gemeinsame Nutzung des Outputs + + + Batch Index + + + Kopieren + + + Öffnen im Bildbetrachter + + + {0} Bilder ausgewählt + + + Ausgabe-Ordner + + + Ausgabetyp + + + Auswahl löschen + + + Alle auswählen + + + An Inference senden + + + Text zu Bild + + + Bild zu Bild + + + Inpainting + + + Hochskalieren + + + Ausgabe-Browser + + + 1 Bild ausgewählt + + + Python Pakete + + + Konsolidieren + + + Sind Sie sicher? + + + Dadurch werden alle generierten Bilder aus den ausgewählten Paketen in das konsolidierte Verzeichnis des gemeinsamen Ausgabeordners verschoben. Diese Aktion kann nicht rückgängig gemacht werden. + + + Aktualisieren + + + Upgrade + + + Downgrade + + + Öffnen in GitHub + + + Verbunden + + + Nicht verbunden + + + Email + + + Nutzername + + + Password + + + Einloggen + + + Anmelden + + + Passwort bestätigen + + + API Schlüssel + + + Preprocessor + + + Stärke + + + Kontrollgewicht + + + Kontrollschritte + + + Sie müssen eingeloggt sein, um diesen Checkpoint herunterladen zu können. Bitte geben Sie einen CivitAI API Schlüssel in den Einstellungen ein. + + + Download fehlgeschlagen + + + Automatische Updates + + + Für Early Adopters. Preview-Builds sind zuverlässiger als die aus dem Dev-Channel und werden näher an den stabilen Versionen verfügbar sein. Ihr Feedback hilft uns sehr dabei, Probleme zu entdecken und Designelemente zu verbessern. + + + Für technische Benutzer. Seien Sie der Erste, der auf unsere Entwicklungs-Builds aus den Funktionszweigen zugreift, sobald sie verfügbar sind. Da wir mit neuen Funktionen experimentieren, kann es noch einige Ecken und Kanten und Bugs geben. + + + Updates + + + Sie sind auf dem aktuellsten Stand + + + Zuletzt überprüft: {0} + + + Trigger Wörter kopieren + + + Trigger Wörter: + + + Zusätzliche Ordner wie IPAdapter und TextualInversions (Einbettungen) können hier aktiviert werden + + + Öffnen in Hugging Face + + + Vorhandene Metadaten aktualisieren + + + Generell + A general settings category + + + Inference + The Inference feature page + + + Prompt + A settings category for Inference generation prompts + + + Ausgabe von Bilddateien + + + Bildbetrachter + + + Automatische Vervollständigung + + + Ersetzen von Unterstrichen durch Leerzeichen beim Einfügen von Vervollständigungen + + + Prompt Tags + Tags for image generation prompts + + + Importiere Prompt Tags + + + Tags-Datei, die zum Vorschlagen von Vervollständigungen verwendet wird (unterstützt das Format a1111-sd-webui-tagcomplete .csv) + + + Systeminformationen + + + CivitAI + + + Hugging Face + + + Zusätze + Inference Sampler Addons + + + Zwischenbild speichern + Inference module step to save an intermediate image + + + Einstellungen + + + Datei auswählen + + + Inhalt ersetzen + + + Noch nicht verfügbar + + + Die Funktion wird in einem zukünftigen Update verfügbar sein + + + Fehlende Bilddatei + + + Urlaubsmodus + + + CLIP überspringen + + + Bild zu Video + + + Bilder pro Sekunde + + + Min CFG + + + Fehlerfrei + + + Bilder + + + Motion Bucket ID + + + Augmentierungslevel + + + Methode + + + Qualität + + \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Languages/Resources.es.resx b/StabilityMatrix.Avalonia/Languages/Resources.es.resx index 46da91606..4dccbf2a7 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.es.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.es.resx @@ -680,7 +680,106 @@ Restablecer Diseño Predeterminado - - CivitAI + + Compartir Resultado + + + Índice de lotes + + + Copiar + + + Abrir en el Visor de imágenes + + + {0} imágenes seleccionadas + + + Carpeta de Resultados + + + Tipo de Resultados + + + Borrar Selección + + + Seleccionar Todo + + + Enviar a Inference + + + Texto a Imagen + + + Imagen a Imagen + + + Pintar Región + + + Escalar + + + Navegador de Resultados + + + 1 imagen seleccionada + + + Paquetes Python + + + Agrupar + + + ¿Estás seguro? + + + Esto moverá todas las imágenes generadas desde los paquetes seleccionados, al directorio 'Grupo' de la carpeta de resultados compartidos. Esta acción no se puede deshacer. + + + Refrescar + + + Mejorar + + + Degradar + + + Abrir en GitHub + + + Conectado + + + Desconectar + + + Email + + + Usuario + + + Contraseña + + + Entrar + + + Registrarme + + + Confirmar Contraseña + + + Clave API + + + Cuentas \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Languages/Resources.fr-FR.resx b/StabilityMatrix.Avalonia/Languages/Resources.fr-FR.resx index 574d80e75..b8eee78d3 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.fr-FR.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.fr-FR.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Lancement + Lancer Quitter @@ -142,7 +142,7 @@ Relancer plus tard - Relance nécessaire + Relancement nécessaire Paquet inconnu @@ -169,10 +169,10 @@ Glisser-déposer les checkpoints ici pour les importer - Accentuation + Emphase - Désaccentuation + Emphase inverse Emebeddings / Inversion textuelle @@ -250,7 +250,7 @@ Une erreur inattendue s'est produite - Demande de sortie + Quitter l'application Nom d'affichage @@ -379,10 +379,10 @@ Déposer le fichier ici pour l'importer - Importer en tant que connecté + Importer avec les métadonnées - Recherche de métadonnées connectées sur les nouvelles importations locales + Recherche de métadonnées en ligne sur les nouvelles importations locales Indexation... @@ -403,10 +403,10 @@ Accord de licence. - Trouver des métadonnées connectées + Trouver des métadonnées en ligne - Afficher les images du modèle + Afficher les vignettes Apparence @@ -448,13 +448,14 @@ Intégrations - Présence riche en discorde + Activer Statut Discord Système - Ajouter la matrice de stabilité au menu de démarrage + Ajouter Stability Matrix au menu de démarrage + updated because "Stability Matrix" was translated in french here haha Utilise l'emplacement actuel de l'application, vous pouvez relancer cette opération si vous déplacez l'application. @@ -521,6 +522,7 @@ Choisissez votre interface préférée et cliquez sur Installer pour commencer. + Fuzzy Installation @@ -556,7 +558,8 @@ Vérifier les mises à jour - Mise à jour + Mettre à jour + switched to verb as it's action label Ajouter un paquet @@ -598,7 +601,7 @@ Redémarrage - Confirmer Supprimer + Confirmer la suppression Cette opération va supprimer le dossier du paquet et tout son contenu, y compris les images générées et les fichiers que vous avez éventuellement ajoutés. @@ -640,7 +643,7 @@ Branche - Défilement automatique jusqu'à la fin de la sortie de la console + Défilement automatique jusqu'à la dernière sortie de la console Licence @@ -667,10 +670,10 @@ Installer maintenant - Notes de mise à jour + Notes de version - Projet ouvert... + Ouvrir projet... Enregistrer sous... @@ -678,7 +681,217 @@ Rétablir la présentation par défaut + + Partager des générations + + + Copier + + + Ouvrir dans la visionneuse d'images + + + {0} images sélectionnées + + + Dossier + Hey hey :) is this used somewhere else ? if it's only inside the "output browser", I suggest to remove "output" from translation, but you're the boss, lmk :) + + + Type + + + Annuler la sélection + + + Sélectionner tout + + + Envoyer à l'inférence + + + Texte vers image + + + Image vers image + + + Amélioration de qualité + + + Explorateur de génération + + + 1 image sélectionnée + + + Paquets Python + + + Consolider + + + Êtes-vous sure ? + + + Cela déplacera toutes les images générées des packages sélectionnés vers le répertoire consolidé du dossier de sorties partagées. Cette action ne peut pas être annulée. + + + Rafraichir + + + Passer à la version supérieure + What is this related to ? + + + Passer à la version inférieure + What is this related to ? + + + Ouvrir sur GitHub + + + Connecté + + + Se déconnecter + + + Email + + + Nom d'utilisateur + + + Mot de passe + + + Ouvrir une session + + + S'enregistrer + + + Confirmer le mot de passe + + + Clé d'API + + + Comptes + + + Préprocesseur + + + Force + + + Vous devez être loggué pour télécharger ce checkpoint. Veuillez entrer une clé d'API CivitAI dans les paramètres. + + + Echec du téléchargement + + + Mises à jour automatiques + + + Pour les early adopters. Version de preview, plus stable que les versions de développement et plus proche des versions stables. Vos retours vont grandement nous aider à identifier des problèmes et ajuster les éléments de design. + + + Pour les utilisateurs expérimentés. Soyez parmis les premiers à accéder aux builds développement incluants les branches de fonctionnalités dès qu'elles sont disponibles. Il peut y avoir quelques disfonctionnements et bugs au fur et à mesure que nous expérimentons de nouvelles fonctionnalités. + + + Mises à jour + + + Vous êtes à jour + + + Dernière vérification: {0} + + + Ouvrir sur Hugging Face + + + Mettre à jour les métadonnées existantes + + + Générale + A general settings category + + + Inférence + The Inference feature page + + + Instruction + A settings category for Inference generation prompts + + + Visionneuse d'images + + + Auto-complétion + + + Informations système + CivitAI + + Hugging Face + + + Modules complémentaires + Inference Sampler Addons + + + Sauvegarder les images intermédiaires + Inference module step to save an intermediate image + + + Paramètres + + + Sélectionner un fichier + + + Remplacer des contenus + + + Pas encore disponible + + + Fonctionnalité disponible dans une mise à jour future + + + Fichier image manquant + + + Image vers vidéo + + + Images par seconde + + + CFG Min + + + Sans perte + + + Images + What is the context on this one ? + + + Motion Bucket ID + + + Méthode + + + Qualité + \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Languages/Resources.ja-JP.resx b/StabilityMatrix.Avalonia/Languages/Resources.ja-JP.resx index 4c5c74958..9449fc9fb 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.ja-JP.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.ja-JP.resx @@ -236,6 +236,7 @@ Patreonになる + fix platform name Discordに参加 @@ -308,7 +309,7 @@ Modelの説明 - Stability Matrixがバージョンアップ! + Stability Matrixを最新版に更新中! 最新版DL @@ -425,6 +426,7 @@ checkpointフォルダ内のシンボリックリンクをシャットダウンか再起動時に削除 + I had mistranslated and rewrite now. I thought it was "when the software exits," but then I realized, given the .net source, that this was to be executed at OS shutdown. Stability Matrix を別のドライブに移動する際に問題が起きた場合、ここにチェック @@ -680,7 +682,56 @@ レイアウトを初期状態に戻す - - CivitAI + + 共有画像フォルダ + + + Image Viewerで開く + + + Outputフォルダ + + + 全て選択 + + + Inferenceに送る + + + Pythonパッケージ + + + これにより、選択したパッケージから生成されたすべてのイメージが、共有出力フォルダの Consolidated ディレクトリに移動します。この操作は元に戻せません。 + + + 更新 + + + ダウンロードにはCivitAIのログインが必要です。SettingからAPIキーを入力してください。 + + + Previewビルドはアーリーアダプター向けです。Devビルドよりも信頼性が高く、安定版に近い状態で利用できます。ぜひ意見や感想を送ってください。問題とデザインの改善に大いに役立ちます。 + + + Devビルドはテクニカルなユーザ向けです。新機能をいち早く利用できます。荒削りな部分やバグがあるかもしれません。 + + + Hugging Faceで開く + + + Metadataをアップデートする + + + 入力補完でアンダースコアをスペースに置き換える + + + 入力補完に使用するタグリスト(拡張機能a1111-sd-webui-tagcomplete内にあるcsvファイルが使えます) + + + フレームレート(FPS) + for jp, "frame rate" is easier to understand, and its better to append FPS. no one can mistake it for a genre of games except nerds + + + 非圧縮 \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Languages/Resources.pt-PT.resx b/StabilityMatrix.Avalonia/Languages/Resources.pt-PT.resx new file mode 100644 index 000000000..9c208fffe --- /dev/null +++ b/StabilityMatrix.Avalonia/Languages/Resources.pt-PT.resx @@ -0,0 +1,933 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Executar + + + Encerrar + + + Salvar + + + Cancelar + + + Idioma + + + Reiniciar + + + Reiniciar + + + Reiniciar mais tarde + + + Necessário Reiniciar + + + Pacote Desconhecido + + + Importar + + + Tipo do Pacote + + + Versão + + + Tipo da Versão + + + Versões + + + Ramificações + + + Arraste e Solte os Checkpoints aqui para Importar + + + Enfatizar + + + Desenfatizar + + + Emebeddings / Inversão Textual + + + Networks (Lora / LyCORIS) + + + Comentários + + + Exibir Pixels Grid com níveis de Zoom + + + Passos + + + Passos - Base + + + Passos - Refinamento + + + Escale de CFG + + + Nível de Denoising + + + Largura + + + Altura + + + Refinamento + + + VAE + + + Modelo + + + Conectar + + + Conectando... + + + Fechar + + + Aguardando pela conecção + + + Novo Update disponível + + + Torne-se um colaborador no Patreon + + + Junte-se ao nosso canal no Discord + + + Downloads + + + Instalação + + + Pular as configurações iniciais + + + Um erro inesperado ocorreu + + + Sair da Aplicação + + + Nome de Exibição + + + Uma instalação com este nome já existe + + + Por favor escolha um novo nome ou escolha um novo local para a instalação + + + Opções Avançadas + + + Salvar alterações + + + Estratégia de Modelo de Pasta compartilhado + + + Versão to PyTorch + + + Fechar diálogo quando finalizar + + + DIretório de Dados + + + Este local é onde os dados do aplicativo (modelos de Checkpoints, Interfaces Web, etc.) serão instaldos + + + Você poderá encontrar erros quando usar discos rígidos com formatação FAT32 ou exFAT. Escolha outro tipo de HD para uma experiência melhor. + + + Modo Portátil + + + Com o Modo Portátil, todos os dados e configurações serão salvos no mesmo diretório da aplicação. Você poderá mover a aplicação com suas pastas de dados para um local diferente em seu computador. + + + Continuar + + + Imagem Anterior + + + Próxima Imagem + + + Descrição do Modelo + + + Uma nova versão do Stability Matrix está disponível + + + Importar última versão + + + Todas as Versões + + + Pesquisar Modelos, #tags, ou @users + + + Pesquisar + + + Filtrar + + + Período + + + Tipo do Modelo + + + Modelo Base + + + Mostar Conteúdo NSFW (adulto) + + + Dados fornecidos pela CivitAI + + + Página + + + Primeira Página + + + Página Anterior + + + Próxima Página + + + Última Página + + + Renomear + + + Apagar + + + Abrir em CivitAI + + + Modelo Conectado + + + Modelo Local + + + Exibir no Explorer + + + Novo + + + Pasta + + + Coloque o arquivo aqui para Importar + + + Importar com Metadados + + + Procurar por Metadados conectados em novos locais de importação + + + Indexando... + + + Pasta de Modelos + + + Catagorias + + + Vamos começar! + + + Ei lí e concordei com o + + + Acordo de Licenciamento + + + Procurar Metadados Conectados + + + Exibir imagens dos Modelos + + + Aparencia + + + Tema + + + Gerenciador de Checkpoint + + + Remove os links simbólico compartilhados do diretorio de Checkpoints ao encerrar + + + Selecione esta opção caso você esteja encontrando problemas ao mover o Stability Matrix para outro HD + + + Resetar o cache de Checkpoints + + + Recriar o cache de Checkpoints instalados. Use caso os Checkpoints estiverem com a descrição incorreta no Selecionador de Modelos + + + Ambiente do Pacote de Instalação + + + Editar + + + Variáveis de Ambiente + + + Python Embutido + + + Verificar Versão + + + Integrações + + + Presença no Discord + + + Sistema + + + Adicionar o Stability Matrix ao Menu Iniciar + + + Utilizar o local atual do aplicativo. Você poderá executar esta opção novamente caso você mover o aplicativo + + + Apenas disponível no Windows + + + Adicionar para o Usuário Atual + + + Adicionar para todos os Usuários + + + Selecionar novo Diretório de Dados + + + Não mover os dados atuais + + + Selecionar o Diretório + + + Sobre + + + Stability Matrix + + + Avisos de licença e código aberto + + + Clique em Iniciar para começar! + + + Parar + + + Enviar comando + + + Input + + + Enviar + + + Input necessário + + + Confirma? + + + Sim + + + Não + + + Abrir a Interface Web + + + Bem-vindo ao Stability Matrix! + + + Escolha sua interface preferida e clique em Instalar para começar + + + Instalando + + + Prosseguindo para a Página de Inicial + + + Baixando pacote... + + + Download completo + + + Instalação completa + + + Instalando pré-requisitos... + + + Instalando requisitos de pacote... + + + Abrir no Explorer + + + Abrir no Finder + + + Desinstalar + + + Verificar se existem atualizações + + + Atualizar + + + Adicionar um pacote + + + Adicione um pacote para iniciar! + + + Nome + + + Valor + + + Remover + + + Detalhes + + + Pilha de chamadas + + + Exceção interna + + + Procurar... + + + OK + + + Tentar novamente + + + Informações da versão do Python + + + Reiniciar + + + Confirmar exclusão + + + Isso excluirá a pasta do pacote e todo o seu conteúdo, incluindo quaisquer imagens e arquivos gerados que você possa ter adicionado. + + + Desinstalando pacote... + + + Pacote desinstalado + + + Alguns arquivos não puderam ser excluídos. Feche todos os arquivos abertos no diretório do pacote e tente novamente. + + + Tipo de pacote inválido + + + Atualizando {0} + + + Atualização completa + + + {0} foi atualizado para a versão mais recente + + + Erro ao atualizar {0} + + + A Atualização falhou + + + Abrir no navegador + + + Erro ao instalar o pacote + + + Branch + + + Rolar automaticamente até o final da saída do console + + + Licença + + + Compartilhamento de modelo + + + Selecione um diretório de dados + + + Nome da pasta de dados + + + Diretório atual: + + + O aplicativo será reiniciado após a atualização + + + Lembre-me mais tarde + + + instale agora + + + Notas de versão + + + Abrir Projeto... + + + Salvar como... + + + Restaurar layout padrão + + + Usar Pasta de saída Compartilhada + + + Índice de lote + + + Copiar + + + Abrir no Visualizador de Imagens + + + {0} imagens selecionadas + + + Pasta de saída + + + Tipo de saída + + + Limpar Seleção + + + Selecionar tudo + + + Enviar para inferência + + + Texto para imagem + + + Imagem para imagem + + + Inpainting (Pintar sobre a imagem) + + + Aumentar o tamanho da Imagem + + + Navegador de Exibição + + + 1 imagem selecionada + + + Pacotes Python + + + Consolidar + + + Tem certeza? + + + Isso moverá todas as imagens geradas dos pacotes selecionados para o diretório Consolidado da pasta de saídas compartilhadas. Essa ação não pode ser desfeita. + + + Atualizar + + + Upgrade + + + Downgrade + + + Abrir no GitHub + + + Conectado + + + Desconectar + + + E-mail + + + Nome de usuário + + + Senha + + + Conectar + + + Cadastrar Conta + + + Confirme sua senha + + + Chave API + + + Contas + + + Pré-processador + + + Força a aplicar + + + Controle de Potência a aplicar + + + Número de Etapas de controle + + + Você deve estar logado para baixar este ponto de verificação. Insira uma chave de API CivitAI nas configurações. + + + O Download falhou + + + Atualizações automáticas + + + Para os usuários Beta. Os Builds (compilações) de pré-visualização serão mais confiáveis do que as do canal Dev e estarão disponíveis mais próximas de versões estáveis. Seu feedback nos ajudará muito a descobrir problemas e aprimorar os elementos do design. + + + Para usuários técnicos. Seja o primeiro a acessar nossos Builds (compilações) de desenvolvimento a partir das versões com novos recursos assim que estes estiverem disponíveis. Podem haver alguns Bugs e pequenos problemas à medida que experimentamos novos recursos. + + + Atualizações + + + Você está atualizado + + + Última verificação: {0} + + + Copiar palavras que executam ações + + + Palavras que executam ações: + + + Pastas adicionais como IPAdapters e TextualInversions (embeddings) podem ser habilitadas aqui + + + Abrir no Hugging Face + + + Atualizar metadados existentes + + + Em geral + A general settings category + + + Inferência + The Inference feature page + + + Incitar + A settings category for Inference generation prompts + + + Arquivos de imagem de saída + + + Visualizador de imagens + + + Preenchimento automático + + + Substitua sublinhados por espaços ao inserir conclusões + + + Etiquetas de prompt + Tags for image generation prompts + + + Tags de prompt de importação + + + Arquivo de tags a ser usado para sugerir conclusões (suporta o formato .csv a1111-sd-webui-tagcomplete) + + + Informação do sistema + + + CivitAI + + + Abraçando o rosto + + + Complementos + Inference Sampler Addons + + + Salvar imagem intermediária + Inference module step to save an intermediate image + + + Configurações + + + Selecione o arquivo + + + Substituir conteúdo + + + Não disponível ainda + + + Este recurso estará disponível em uma atualização futura + + + Arquivo de imagem não encontrado + + + Modo Férias + + + Pular CLIPE + + + Imagem para vídeo + + + Quadros por segundo + + + CFG mínimo + + + Sem perdas + + + Frames + + + ID do Motion Bucket + + + Nível de aumento + + + Método + + \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 93cdca802..4b038c760 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -520,7 +520,7 @@ Welcome to Stability Matrix! - Choose your preferred interface and click Install to get started + Choose your preferred interface to get started Installing @@ -897,4 +897,67 @@ Holiday Mode + + CLIP Skip + + + Image to Video + + + Frames Per Second + + + Min CFG + + + Lossless + + + Frames + + + Motion Bucket ID + + + Augmentation Level + + + Method + + + Quality + + + Find in Model Browser + + + Installed + + + No extensions found. + + + Hide + + + Copy Details + + + Notifications + + + + + + Download + + + Check the progress of your package installations and model downloads here. + + + Recommended Models + + + While your package is installing, here are some models we recommend to help you get started. + diff --git a/StabilityMatrix.Avalonia/Languages/Resources.tr-TR.resx b/StabilityMatrix.Avalonia/Languages/Resources.tr-TR.resx index f15681435..603f6feb2 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.tr-TR.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.tr-TR.resx @@ -133,7 +133,7 @@ Dil - Yeni dil seçeneğinin etkili olması için yeniden başlatma gerekir. + Yeni dil seçeneğinin etkili olması için yeniden başlatma gerekiyor Yeniden başlat @@ -166,7 +166,7 @@ Dallar - İçe aktarmak için chekpoints'leri buraya sürükleyip bırakın. + İçe aktarmak için chekpoints'leri buraya sürükleyip bırakın Vurgu @@ -235,7 +235,7 @@ Sponsor Ol - Discord Sunucumuza Katıl + Discord Sunucusuna Katıl İndirmeler @@ -313,22 +313,22 @@ Tüm Sürümler - Modelleri,Tagları (#tag) veya Kullanıcıları (@user) Buradan Arayabilirsin + Modelleri, #etiketleri veya @kullanıcıları ara Ara - Sıralama Tipi + Sırala - Sıralama Tarihi + Süre Model Türü - Model Altyapısı + Temel Model NSFW İçerik Göster @@ -376,10 +376,10 @@ Klasör - Dosyayı içeri aktarmak için lütfen sürükleyip işaretlenen alana bırakın. + İçe aktarma için dosyayı buraya bırakın - Dosyaları metadata ile içeri aktar + Metadata ile içeri aktar Yeni yerel içe aktarmalar için bağlı meta veri arayın @@ -448,7 +448,7 @@ Entegrasyonlar - Discord Etkinliğini StabilityMatrix Olarak Göster. + Discord Zengin Varlık Sistem @@ -469,10 +469,10 @@ Tüm Kullanıcılar İçin Ekle - Yeni Dosya Dizinini Seç + Yeni Veri Dizini Seç - Bu işlem mevcut veriyi yeni dizine taşımaz. + Mevcut veriyi taşımaz Dizin Seçin @@ -490,7 +490,7 @@ Başlamak için Başlat'a tıklayın! - Durdur + Dur Giriş Gönder @@ -694,10 +694,10 @@ {0} resim seçildi - Çıktı Klasörü + Çıkış Klasörü - Çıktı Türü + Çıkış Türü Seçimi Temizle @@ -721,7 +721,7 @@ Upscale - Medya Kitaplığı + Çıkış Tarayıcısı 1 resim seçildi @@ -780,4 +780,157 @@ Hesaplar - + + Önişlemci + + + Kuvvet + + + Kontrol Ağırlığı + + + Kontrol Adımları + + + Bu kontrol noktasını indirmek için giriş yapmalısınız. Lütfen ayarlara bir CivitAI API Anahtarı girin. + + + İndirme Başarısız + + + Otomatik Güncellemeler + + + Erken benimseyenler için. Önizleme yapıları, Geliştirme kanalındakilerden daha güvenilir olacak ve kararlı sürümlere daha yakın olacak. Geri bildiriminiz sorunları keşfetmemizde ve tasarım öğelerini geliştirmemizde bize büyük ölçüde yardımcı olacaktır. + + + Teknik kullanıcılar için. Geliştirme yapılarımıza, kullanıma sunuldukları anda özellik dallarından ilk erişen siz olun. Yeni özellikleri denedikçe bazı pürüzlü noktalar ve hatalar olabilir. + + + Güncellemeler + + + Güncelsin + + + Son kontrol: {0} + + + Tetikleyici Kelimeleri Kopyala + + + Tetikleyici kelimeler: + + + IPAdapters ve TextualInversions (embeddings) gibi ek klasörler burada etkinleştirilebilir + + + Hugging Face'de aç + + + Mevcut Meta Verileri Güncelle + + + Genel + A general settings category + + + Çıkarım + The Inference feature page + + + Komut + A settings category for Inference generation prompts + + + Çıktı Resim Dosyaları + + + Resim görüntüleyici + + + Otomatik Tamamlama + + + Tamamlamaları eklerken alt çizgileri boşluklarla değiştirin + + + Komut Etiketleri + Tags for image generation prompts + + + Komut etiketlerini içe aktar + + + Tamamlamaları önermek için kullanılacak etiket dosyası (a1111-sd-webui-tagcomplete .csv formatını destekler) + + + Sistem bilgisi + + + CivitAI + + + Hugging Face + + + Eklentiler + Inference Sampler Addons + + + Ara Resmi Kaydet + Inference module step to save an intermediate image + + + Ayarlar + + + Dosya Seç + + + İçeriği Değiştir + + + Henüz uygun değil + + + Özellik gelecekteki bir güncellemede kullanıma sunulacak + + + Eksik Resim Dosyası + + + Tatil Modu + + + CLIP Atla + + + Resimden Videoya + + + Saniyedeki Kare Sayısı + + + Min. CFG + + + Kayıpsız + + + Çerçeveler + + + Motion Bucket ID + + + Arttırma Seviyesi + + + Yöntem + + + Kalite + + \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Models/CommandItem.cs b/StabilityMatrix.Avalonia/Models/CommandItem.cs new file mode 100644 index 000000000..5c2089d9b --- /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 000000000..34c998d77 --- /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 4a8fffb73..973b0bf6c 100644 --- a/StabilityMatrix.Avalonia/Models/ImageSource.cs +++ b/StabilityMatrix.Avalonia/Models/ImageSource.cs @@ -1,18 +1,21 @@ using System; using System.Diagnostics; using System.IO; +using System.Net.Http; using System.Text.Json.Serialization; using System.Threading.Tasks; using AsyncImageLoader; using Avalonia.Media.Imaging; using Blake3; +using Microsoft.Extensions.DependencyInjection; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Webp; using StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Avalonia.Models; -public record ImageSource : IDisposable +public record ImageSource : IDisposable, ITemplateKey { private Hash? contentHashBlake3; @@ -55,6 +58,80 @@ public ImageSource(Bitmap bitmap) Bitmap = bitmap; } + /// + public ImageSourceTemplateType TemplateKey { get; private set; } + + private async Task TryRefreshTemplateKeyAsync() + { + if ((LocalFile?.Extension ?? Path.GetExtension(RemoteUrl?.ToString())) is not { } extension) + { + return false; + } + + if (extension.Equals(".webp", StringComparison.OrdinalIgnoreCase)) + { + if (LocalFile is not null && LocalFile.Exists) + { + await using var stream = LocalFile.Info.OpenRead(); + using var reader = new WebpReader(stream); + + try + { + TemplateKey = reader.GetIsAnimatedFlag() + ? ImageSourceTemplateType.WebpAnimation + : ImageSourceTemplateType.Image; + } + catch (InvalidDataException) + { + return false; + } + + return true; + } + + if (RemoteUrl is not null) + { + var httpClientFactory = App.Services.GetRequiredService(); + using var client = httpClientFactory.CreateClient(); + + try + { + await using var stream = await client.GetStreamAsync(RemoteUrl); + using var reader = new WebpReader(stream); + + TemplateKey = reader.GetIsAnimatedFlag() + ? ImageSourceTemplateType.WebpAnimation + : ImageSourceTemplateType.Image; + } + catch (Exception) + { + return false; + } + + return true; + } + + return false; + } + + TemplateKey = ImageSourceTemplateType.Image; + + return true; + } + + public async Task GetOrRefreshTemplateKeyAsync() + { + if (TemplateKey is ImageSourceTemplateType.Default) + { + await TryRefreshTemplateKeyAsync(); + } + + return TemplateKey; + } + + [JsonIgnore] + public Task TemplateKeyAsync => GetOrRefreshTemplateKeyAsync(); + [JsonIgnore] public Task BitmapAsync => GetBitmapAsync(); diff --git a/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs b/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs new file mode 100644 index 000000000..14ab15d4f --- /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 f90baa4bf..8119abc16 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 000000000..134a3c0b0 --- /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 3b8c1cadf..3cc0355ed 100644 --- a/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs +++ b/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs @@ -33,7 +33,7 @@ public static InferenceProjectDocument FromLoadable(IJsonLoadableState loadableM 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 eaae74ebb..1b4bb0178 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 000000000..18982f9ad --- /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/Models/SelectableItem.cs b/StabilityMatrix.Avalonia/Models/SelectableItem.cs new file mode 100644 index 000000000..599884ea8 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/SelectableItem.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Windows.Input; +using CommunityToolkit.Mvvm.Input; +using DynamicData.Binding; +using JetBrains.Annotations; + +namespace StabilityMatrix.Avalonia.Models; + +[PublicAPI] +public class SelectableItem(T item) : AbstractNotifyPropertyChanged, IEquatable> +{ + public T Item { get; } = item; + + private bool _isSelected; + + public bool IsSelected + { + get => _isSelected; + set => SetAndRaise(ref _isSelected, value); + } + + public ICommand ToggleSelectedCommand => new RelayCommand(() => IsSelected = !IsSelected); + + /// + public bool Equals(SelectableItem? other) + { + if (ReferenceEquals(null, other)) + return false; + if (ReferenceEquals(this, other)) + return true; + return EqualityComparer.Default.Equals(Item, other.Item); + } + + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj.GetType() != GetType()) + return false; + return Equals((SelectableItem)obj); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(GetType().GetHashCode(), Item?.GetHashCode()); + } +} diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 357771679..e034c61c8 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -25,6 +25,7 @@ using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Updater; namespace StabilityMatrix.Avalonia; @@ -55,8 +56,7 @@ public static void Main(string[] args) SetDebugBuild(); var parseResult = Parser - .Default - .ParseArguments(args) + .Default.ParseArguments(args) .WithNotParsed(errors => { foreach (var error in errors) @@ -65,7 +65,7 @@ public static void Main(string[] args) } }); - Args = parseResult.Value; + Args = parseResult.Value ?? new AppArgs(); if (Args.HomeDirectoryOverride is { } homeDir) { @@ -147,7 +147,10 @@ public static AppBuilder BuildAvaloniaApp() if (Args.UseOpenGlRendering) { app = app.With( - new Win32PlatformOptions { RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software] } + new Win32PlatformOptions + { + RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software] + } ); } @@ -156,7 +159,10 @@ public static AppBuilder BuildAvaloniaApp() app = app.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } }) .With(new X11PlatformOptions { RenderingMode = new[] { X11RenderingMode.Software } }) .With( - new AvaloniaNativePlatformOptions { RenderingMode = new[] { AvaloniaNativeRenderingMode.Software } } + new AvaloniaNativePlatformOptions + { + RenderingMode = new[] { AvaloniaNativeRenderingMode.Software } + } ); } @@ -173,8 +179,6 @@ private static void HandleUpdateReplacement() return; // Copy our current file to the parent directory, overwriting the old app file - var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName()); - var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); var isCopied = false; @@ -188,7 +192,27 @@ var delay in Backoff.DecorrelatedJitterBackoffV2( { try { - currentExe.CopyTo(targetExe, true); + if (Compat.IsMacOS) + { + var currentApp = Compat.AppBundleCurrentPath!; + var targetApp = parentDir.JoinDir(Compat.GetAppName()); + + // Since macOS has issues with signature caching, delete the target app first + if (targetApp.Exists) + { + targetApp.Delete(true); + } + + currentApp.CopyTo(targetApp); + } + else + { + var currentExe = Compat.AppCurrentPath; + var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); + + currentExe.CopyTo(targetExe, true); + } + isCopied = true; break; } @@ -204,11 +228,13 @@ var delay in Backoff.DecorrelatedJitterBackoffV2( Environment.Exit(1); } + var targetAppOrBundle = Path.Combine(parentDir, Compat.GetAppName()); + // Ensure permissions are set for unix if (Compat.IsUnix) { File.SetUnixFileMode( - targetExe, // 0755 + targetAppOrBundle, // 0755 UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute @@ -220,7 +246,10 @@ var delay in Backoff.DecorrelatedJitterBackoffV2( } // Start the new app while passing our own PID to wait for exit - Process.Start(targetExe, $"--wait-for-exit-pid {Environment.ProcessId}"); + ProcessRunner.StartApp( + targetAppOrBundle, + new[] { "--wait-for-exit-pid", $"{Environment.ProcessId}" } + ); // Shutdown the current app Environment.Exit(0); @@ -274,7 +303,8 @@ private static void ConfigureSentry() { SentrySdk.Init(o => { - o.Dsn = "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328"; + o.Dsn = + "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328"; o.StackTraceMode = StackTraceMode.Enhanced; o.TracesSampleRate = 1.0; o.IsGlobalModeEnabled = true; @@ -301,7 +331,10 @@ private static void ConfigureSentry() }); } - private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + private static void TaskScheduler_UnobservedTaskException( + object? sender, + UnobservedTaskExceptionEventArgs e + ) { if (e.Exception is Exception ex) { @@ -314,11 +347,13 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc if (e.ExceptionObject is not Exception ex) return; + SentryId? sentryId = null; + // Exception automatically logged by Sentry if enabled if (SentrySdk.IsEnabled) { ex.SetSentryMechanism("AppDomain.UnhandledException", handled: false); - SentrySdk.CaptureException(ex); + sentryId = SentrySdk.CaptureException(ex); SentrySdk.FlushAsync().SafeFireAndForget(); } else @@ -328,7 +363,10 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) { - var dialog = new ExceptionDialog { DataContext = new ExceptionViewModel { Exception = ex } }; + var dialog = new ExceptionDialog + { + DataContext = new ExceptionViewModel { Exception = ex, SentryId = sentryId } + }; var mainWindow = lifetime.MainWindow; // We can only show dialog if main window exists, and is visible diff --git a/StabilityMatrix.Avalonia/Services/INavigationService.cs b/StabilityMatrix.Avalonia/Services/INavigationService.cs index 3f7d0885a..f1aa8ba44 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 @@ void NavigateTo( /// Navigate to the view of the given view model. /// void NavigateTo(ViewModelBase viewModel, NavigationTransitionInfo? transitionInfo = null); + + bool GoBack(); } diff --git a/StabilityMatrix.Avalonia/Services/INotificationService.cs b/StabilityMatrix.Avalonia/Services/INotificationService.cs index d3cc7e773..77f63f60d 100644 --- a/StabilityMatrix.Avalonia/Services/INotificationService.cs +++ b/StabilityMatrix.Avalonia/Services/INotificationService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Settings; namespace StabilityMatrix.Avalonia.Services; @@ -47,6 +48,20 @@ Task> TryAsync( NotificationType appearance = NotificationType.Error ); + /// + /// Show a keyed customizable persistent notification with the given parameters. + /// + Task ShowPersistentAsync(NotificationKey key, DesktopNotifications.Notification notification); + + /// + /// Show a keyed customizable notification with the given parameters. + /// + Task ShowAsync( + NotificationKey key, + DesktopNotifications.Notification notification, + TimeSpan? expiration = null + ); + /// /// Show a notification with the given parameters. /// @@ -77,4 +92,9 @@ void ShowPersistent( NotificationType appearance = NotificationType.Error, LogLevel logLevel = LogLevel.Warning ); + + /// + /// Get the native notification manager. + /// + Task GetNativeNotificationManagerAsync(); } diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs index 5b4bd6303..c95d3068d 100644 --- a/StabilityMatrix.Avalonia/Services/NavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/NavigationService.cs @@ -7,7 +7,9 @@ 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 void SetFrame(Frame frame) } /// - 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 void NavigateTo( } ); - TypedNavigation?.Invoke( - this, - new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) } - ); + TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) }); } /// @@ -120,10 +120,7 @@ public void NavigateTo( } ); - TypedNavigation?.Invoke( - this, - new TypedNavigationEventArgs { ViewModelType = viewModelType } - ); + TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = viewModelType }); } /// @@ -159,13 +156,36 @@ public void NavigateTo(ViewModelBase viewModel, NavigationTransitionInfo? transi } ); + 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/Services/NotificationService.cs b/StabilityMatrix.Avalonia/Services/NotificationService.cs index 98611f8d4..2c6b3da28 100644 --- a/StabilityMatrix.Avalonia/Services/NotificationService.cs +++ b/StabilityMatrix.Avalonia/Services/NotificationService.cs @@ -3,23 +3,32 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Notifications; +using Avalonia.Threading; +using DesktopNotifications.FreeDesktop; +using DesktopNotifications.Windows; using Microsoft.Extensions.Logging; +using Nito.AsyncEx; +using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Exceptions; +using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Services; +using INotificationManager = DesktopNotifications.INotificationManager; namespace StabilityMatrix.Avalonia.Services; [Singleton(typeof(INotificationService))] -public class NotificationService : INotificationService +public class NotificationService(ILogger logger, ISettingsManager settingsManager) + : INotificationService, + IDisposable { - private readonly ILogger logger; private WindowNotificationManager? notificationManager; - public NotificationService(ILogger logger) - { - this.logger = logger; - } + private readonly AsyncLock nativeNotificationManagerLock = new(); + private volatile INotificationManager? nativeNotificationManager; + private volatile bool isNativeNotificationManagerInitialized; public void Initialize( Visual? visual, @@ -41,6 +50,115 @@ public void Show(INotification notification) notificationManager?.Show(notification); } + /// + public Task ShowPersistentAsync(NotificationKey key, DesktopNotifications.Notification notification) + { + return ShowAsyncCore(key, notification, null, true); + } + + /// + public Task ShowAsync( + NotificationKey key, + DesktopNotifications.Notification notification, + TimeSpan? expiration = null + ) + { + // Use default expiration if not specified + expiration ??= TimeSpan.FromSeconds(5); + + return ShowAsyncCore(key, notification, expiration, false); + } + + private async Task ShowAsyncCore( + NotificationKey key, + DesktopNotifications.Notification notification, + TimeSpan? expiration, + bool isPersistent + ) + { + // If settings has option preference, use that, otherwise default + if (!settingsManager.Settings.NotificationOptions.TryGetValue(key, out var option)) + { + option = key.DefaultOption; + } + + switch (option) + { + case NotificationOption.None: + break; + case NotificationOption.NativePush: + { + // If native option is not supported, fallback to toast + if (await GetNativeNotificationManagerAsync() is not { } nativeManager) + { + // Show app toast + if (isPersistent) + { + Dispatcher.UIThread.Invoke( + () => + ShowPersistent( + notification.Title ?? "", + notification.Body ?? "", + key.Level.ToNotificationType() + ) + ); + } + else + { + Dispatcher.UIThread.Invoke( + () => + Show( + notification.Title ?? "", + notification.Body ?? "", + key.Level.ToNotificationType(), + expiration + ) + ); + } + return; + } + + // Show native notification + await nativeManager.ShowNotification( + notification, + expiration is null ? null : DateTimeOffset.Now.Add(expiration.Value) + ); + + break; + } + case NotificationOption.AppToast: + // Show app toast + if (isPersistent) + { + Dispatcher.UIThread.Invoke( + () => + ShowPersistent( + notification.Title ?? "", + notification.Body ?? "", + key.Level.ToNotificationType() + ) + ); + } + else + { + Dispatcher.UIThread.Invoke( + () => + Show( + notification.Title ?? "", + notification.Body ?? "", + key.Level.ToNotificationType(), + expiration + ) + ); + } + + break; + default: + logger.LogError("Unknown notification option {Option}", option); + break; + } + } + public void Show( string title, string message, @@ -111,4 +229,52 @@ public async Task> TryAsync( return new TaskResult(false, e); } } + + public void Dispose() + { + nativeNotificationManager?.Dispose(); + + GC.SuppressFinalize(this); + } + + public async Task GetNativeNotificationManagerAsync() + { + if (isNativeNotificationManagerInitialized) + return nativeNotificationManager; + + using var _ = await nativeNotificationManagerLock.LockAsync(); + + if (isNativeNotificationManagerInitialized) + return nativeNotificationManager; + + try + { + if (Compat.IsWindows) + { + var context = WindowsApplicationContext.FromCurrentProcess("Stability Matrix"); + nativeNotificationManager = new WindowsNotificationManager(context); + + await nativeNotificationManager.Initialize(); + } + else if (Compat.IsLinux) + { + var context = FreeDesktopApplicationContext.FromCurrentProcess(); + nativeNotificationManager = new FreeDesktopNotificationManager(context); + + await nativeNotificationManager.Initialize(); + } + else + { + logger.LogInformation("Native notifications are not supported on this platform"); + } + } + catch (Exception e) + { + logger.LogError(e, "Failed to initialize native notification manager"); + } + + isNativeNotificationManagerInitialized = true; + + return nativeNotificationManager; + } } diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 54e5d85be..e6d4277ae 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -1,19 +1,35 @@  + + net8.0 + + + net8.0-windows10.0.17763.0 + + + Stability Matrix + Assets/AppIcon.icns + APPL WinExe - net8.0 win-x64;linux-x64;osx-x64;osx-arm64 enable true app.manifest true ./Assets/Icon.ico - 2.7.0-pre.999 + 2.8.0-dev.999 $(Version) true true - + + + + StabilityMatrix.URL + stabilitymatrix;stabilitymatrix:// + + + @@ -22,30 +38,35 @@ - + + - - - + + + - + - - - - + + + + + + + + - - - + + + @@ -56,17 +77,17 @@ - + - - + + - + - + @@ -91,6 +112,7 @@ + @@ -128,6 +150,12 @@ + + + + Always + + @@ -142,6 +170,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 000000000..7565401ba --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml @@ -0,0 +1,254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml index ece8eea20..e49ef7547 100644 --- a/StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml @@ -2,26 +2,51 @@ 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.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" xmlns:ui="using:FluentAvalonia.UI.Controls"> + + + + + + + + + + + + + + + + + + + + + + + - + Symbol="{Binding $parent[controls:HyperlinkIconButton].Icon}" /> + @@ -39,6 +64,23 @@ + + + + + diff --git a/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml index d0c2007ed..55d68fd35 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.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" + 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"> - - + + + + - + - - + - + - + diff --git a/StabilityMatrix.Avalonia/Styles/TextBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/TextBoxStyles.axaml new file mode 100644 index 000000000..9026bc334 --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/TextBoxStyles.axaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs index 08a41a644..9cbf2e182 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 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.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; @@ -33,7 +37,9 @@ using StabilityMatrix.Core.Models.Api.Comfy.Nodes; using StabilityMatrix.Core.Models.Api.Comfy.WebSocketData; using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Services; +using Notification = DesktopNotifications.Notification; namespace StabilityMatrix.Avalonia.ViewModels.Base; @@ -42,7 +48,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 +99,22 @@ protected Task WriteOutputImageAsync( 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 +126,8 @@ protected async Task WriteOutputImageAsync( ImageGenerationEventArgs args, int batchNum = 0, int batchTotal = 0, - bool isGrid = false + bool isGrid = false, + string fileExtension = "png" ) { var formatTemplateStr = settingsManager.Settings.InferenceOutputImageFileNameFormat; @@ -128,7 +146,10 @@ protected async Task WriteOutputImageAsync( ) { // 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 +165,7 @@ protected async Task WriteOutputImageAsync( } 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 +173,19 @@ protected async Task WriteOutputImageAsync( 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}"); + } + + if (file.Info.DirectoryName != null) + { + Directory.CreateDirectory(file.Info.DirectoryName); } await using var fileStream = file.Info.OpenWrite(); @@ -271,17 +297,14 @@ protected async Task RunGeneration(ImageGenerationEventArgs args, CancellationTo 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 +328,17 @@ await DialogHelper // 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 +350,19 @@ await DialogHelper ImageGalleryCardViewModel.ImageSources.Clear(); } - await ProcessAllOutputImages(imageOutputs, args); + var outputImages = await ProcessOutputImages(images, args); + + var notificationImage = outputImages.FirstOrDefault()?.LocalFile; + + await notificationService.ShowAsync( + NotificationKey.Inference_PromptCompleted, + new Notification + { + Title = "Prompt Completed", + Body = $"Prompt [{promptTask.Id[..7].ToLower()}] completed successfully", + BodyImagePath = notificationImage?.FullPath + } + ); } finally { @@ -338,30 +380,12 @@ await DialogHelper } } - 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( + private async Task> ProcessOutputImages( IReadOnlyCollection images, - ImageGenerationEventArgs args, - string? imageLabel = null + ImageGenerationEventArgs args ) { var client = args.Client; @@ -405,14 +429,64 @@ private async Task ProcessOutputImages( ); } - 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,25 +504,27 @@ private async Task ProcessOutputImages( 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 }; - - // Preload - await gridImage.GetBitmapAsync(); - ImageGalleryCardViewModel.ImageSources.Add(gridImage); - + var gridImage = new ImageSource(gridPath); + outputImages.Insert(0, gridImage); EventManager.Instance.OnImageFileAdded(gridPath); } - // Add rest of images foreach (var img in outputImages) { // Preload await img.GetBitmapAsync(); + // Add images ImageGalleryCardViewModel.ImageSources.Add(img); } + + return outputImages; } /// @@ -465,7 +541,10 @@ protected virtual Task GenerateImageImpl(GenerateOverrides overrides, Cancellati /// 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 +554,7 @@ private async Task GenerateImage(GenerateFlags options = default, CancellationTo } 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 +587,15 @@ protected virtual void OnPreviewImageReceived(object? sender, ComfyWebSocketImag /// 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 +620,13 @@ protected virtual void OnRunningNodeChanged(object? sender, string? nodeName) 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 965b787fc..fdd9a520a 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 virtual void LoadStateFromJsonObject(JsonObject state) { // 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 virtual void LoadStateFromJsonObject(JsonObject state) // 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 @@ is JsonPropertyNameAttribute jsonPropertyName 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 @@ is not { } constructorInfo } 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 virtual JsonObject SaveStateToJsonObject() { // 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 virtual JsonObject SaveStateToJsonObject() // 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 @@ is JsonPropertyNameAttribute jsonPropertyName } 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 virtual void LoadStateFromJsonObject(JsonObject state, int version) 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 4dc4acbc2..afdeb0855 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs @@ -21,6 +21,7 @@ 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 @@ private void CheckIfInstalled() 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 @@ private void CheckIfInstalled() ShowUpdateCard = anyVersionInstalled; } - // Choose and load image based on nsfw setting private void UpdateImage() { var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled; @@ -162,21 +156,17 @@ private void UpdateImage() 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 @@ private static string PruneDescription(CivitModel model) { 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 @@ DirectoryPath downloadDirectory 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 @@ private async Task DoImport( } // 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 @@ private async Task DoImport( 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/CheckpointBrowser/CivitAiBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs index 2911dc6be..924d17ed8 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs @@ -18,6 +18,7 @@ using LiteDB; using LiteDB.Async; using NLog; +using OneOf.Types; using Refit; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; @@ -129,18 +130,8 @@ private LRUCache< .Where(t => t == CivitModelType.All || t.ConvertTo() > 0) .OrderBy(t => t.ToString()); - public List BaseModelOptions => - [ - "All", - "SD 1.5", - "SD 1.5 LCM", - "SD 2.1", - "SDXL 0.9", - "SDXL 1.0", - "SDXL 1.0 LCM", - "SDXL Turbo", - "Other" - ]; + public IEnumerable BaseModelOptions => + Enum.GetValues().Select(t => t.GetStringValue()); public CivitAiBrowserViewModel( ICivitApi civitApi, @@ -170,6 +161,17 @@ INotificationService notificationService .Where(page => page <= TotalPages && page > 0) .ObserveOn(SynchronizationContext.Current) .Subscribe(_ => TrySearchAgain(false).SafeFireAndForget(), err => Logger.Error(err)); + + EventManager.Instance.NavigateAndFindCivitModelRequested += OnNavigateAndFindCivitModelRequested; + } + + private void OnNavigateAndFindCivitModelRequested(object? sender, int e) + { + if (e <= 0) + return; + + SearchQuery = $"$#{e}"; + SearchModelsCommand.ExecuteAsync(null).SafeFireAndForget(); } public override void OnLoaded() @@ -409,6 +411,16 @@ private async Task SearchModels() Page = CurrentPageNumber }; + if (SelectedModelType != CivitModelType.All) + { + modelRequest.Types = [SelectedModelType]; + } + + if (SelectedBaseModelType != "All") + { + modelRequest.BaseModel = SelectedBaseModelType; + } + if (SearchQuery.StartsWith("#")) { modelRequest.Tag = SearchQuery[1..]; @@ -417,19 +429,22 @@ private async Task SearchModels() { modelRequest.Username = SearchQuery[1..]; } - else + else if (SearchQuery.StartsWith("$#")) { - modelRequest.Query = SearchQuery; - } + modelRequest.Period = CivitPeriod.AllTime; + modelRequest.BaseModel = null; + modelRequest.Types = null; + modelRequest.CommaSeparatedModelIds = SearchQuery[2..]; - if (SelectedModelType != CivitModelType.All) - { - modelRequest.Types = new[] { SelectedModelType }; + if (modelRequest.Sort is CivitSortMode.Favorites or CivitSortMode.Installed) + { + SortMode = CivitSortMode.HighestRated; + modelRequest.Sort = CivitSortMode.HighestRated; + } } - - if (SelectedBaseModelType != "All") + else { - modelRequest.BaseModel = SelectedBaseModelType; + modelRequest.Query = SearchQuery; } if (SortMode == CivitSortMode.Installed) diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/HuggingFacePageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/HuggingFacePageViewModel.cs index 0f3804a09..9fae11139 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/HuggingFacePageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/HuggingFacePageViewModel.cs @@ -8,6 +8,7 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.Notifications; using Avalonia.Data; using Avalonia.Input; @@ -79,7 +80,10 @@ INotificationService notificationService .Group(i => i.ModelCategory) .Transform( g => - new CategoryViewModel(g.Cache.Items) + new CategoryViewModel( + g.Cache.Items, + Design.IsDesignMode ? string.Empty : settingsManager.ModelsDirectory + ) { Title = g.Key.GetDescription() ?? g.Key.ToString() } @@ -149,7 +153,7 @@ private async Task ImportSelected() var sharedFolderType = viewModel.Item.ModelCategory.ConvertTo(); var downloadPath = new FilePath( Path.Combine( - settingsManager.ModelsDirectory, + Design.IsDesignMode ? string.Empty : settingsManager.ModelsDirectory, sharedFolderType.ToString(), viewModel.Item.Subfolder ?? string.Empty, file diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs index e477851d3..fd2ccc1f3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs @@ -1,32 +1,51 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; -using Avalonia; +using System.Threading.Tasks; using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; +using FluentAvalonia.Core; using FluentAvalonia.UI.Controls; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; [View(typeof(CheckpointBrowserPage))] [Singleton] -public partial class CheckpointBrowserViewModel( - CivitAiBrowserViewModel civitAiBrowserViewModel, - HuggingFacePageViewModel huggingFaceViewModel -) : PageViewModelBase +public partial class CheckpointBrowserViewModel : PageViewModelBase { public override string Title => "Model Browser"; - public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.BrainCircuit, IsFilled = true }; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.BrainCircuit, IsFilled = true }; - public IReadOnlyList Pages { get; } = - new List( + public IReadOnlyList Pages { get; } + + [ObservableProperty] + private TabItem? selectedPage; + + /// + public CheckpointBrowserViewModel( + CivitAiBrowserViewModel civitAiBrowserViewModel, + HuggingFacePageViewModel huggingFaceViewModel + ) + { + Pages = new List( new List([civitAiBrowserViewModel, huggingFaceViewModel]).Select( vm => new TabItem { Header = vm.Header, Content = vm } ) ); + SelectedPage = Pages.FirstOrDefault(); + EventManager.Instance.NavigateAndFindCivitModelRequested += OnNavigateAndFindCivitModelRequested; + } + + private void OnNavigateAndFindCivitModelRequested(object? sender, int e) + { + SelectedPage = Pages.FirstOrDefault(); + } } diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/BaseModelOptionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/BaseModelOptionViewModel.cs new file mode 100644 index 000000000..b44b5d6f3 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/BaseModelOptionViewModel.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StabilityMatrix.Avalonia.ViewModels.CheckpointManager; + +public partial class BaseModelOptionViewModel : ObservableObject +{ + [ObservableProperty] + private bool isSelected; + + [ObservableProperty] + private string modelType = string.Empty; +} diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs index 333a3a02a..f3b817885 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs @@ -63,6 +63,9 @@ public partial class CheckpointFile : ViewModelBase [ObservableProperty] private ProgressReport? progress; + [ObservableProperty] + private bool updateAvailable; + public string FileName => Path.GetFileName(FilePath); public bool CanShowTriggerWords => @@ -70,7 +73,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 +105,9 @@ private string GetConnectedModelInfoFilePath() { 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) ?? ""; @@ -203,6 +215,15 @@ private async Task RenameAsync() } } + [RelayCommand] + private void FindOnModelBrowser() + { + if (ConnectedModel?.ModelId == null) + return; + + EventManager.Instance.OnNavigateAndFindCivitModelRequested(ConnectedModel.ModelId); + } + [RelayCommand] private void OpenOnCivitAi() { @@ -224,10 +245,21 @@ private Task CopyTriggerWords() 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 +330,9 @@ public static IEnumerable FromDirectoryIndex( } 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 +358,11 @@ public static IEnumerable GetAllCheckpointFiles(string modelsDir ) 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 +470,6 @@ public int GetHashCode(CheckpointFile obj) } } - public static IEqualityComparer FilePathComparer { get; } = new FilePathEqualityComparer(); + public static IEqualityComparer FilePathComparer { get; } = + new FilePathEqualityComparer(); } diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs index cfb4e373f..ab777a448 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs @@ -22,6 +22,7 @@ using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -43,6 +44,8 @@ public partial class CheckpointFolder : ViewModelBase private readonly SourceCache checkpointFilesCache = new(x => x.FilePath); + public readonly SourceCache BaseModelOptionsCache = new(x => x); + // ReSharper disable once FieldCanBeMadeReadOnly.Local private bool useCategoryVisibility; @@ -105,6 +108,9 @@ public partial class CheckpointFolder : ViewModelBase public IObservableCollection DisplayedCheckpointFiles { get; set; } = new ObservableCollectionExtended(); + public IObservableCollection BaseModelOptions { get; } = + new ObservableCollectionExtended(); + public CheckpointFolder( ISettingsManager settingsManager, IDownloadService downloadService, @@ -131,16 +137,13 @@ public CheckpointFolder( .Subscribe(_ => checkpointFilesCache.Remove(file)) ) .Bind(CheckpointFiles) + .Filter(ContainsSearchFilter) + .Filter(BaseModelFilter) .Sort( SortExpressionComparer .Descending(f => f.IsConnectedModel) .ThenByAscending(f => f.IsConnectedModel ? f.ConnectedModel!.ModelName : f.FileName) ) - .Filter( - f => - f.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) - || f.Title.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) - ) .Bind(DisplayedCheckpointFiles) .Subscribe(); @@ -157,10 +160,61 @@ public CheckpointFolder( .Bind(SubFolders) .Subscribe(); + BaseModelOptionsCache + .Connect() + .DeferUntilLoaded() + .Bind(BaseModelOptions) + .Subscribe(_ => + { + foreach (var subFolder in SubFolders) + { + subFolder.BaseModelOptionsCache.EditDiff(BaseModelOptions); + } + + checkpointFilesCache.Refresh(); + SubFoldersCache.Refresh(); + }); + + BaseModelOptionsCache.AddOrUpdate( + Enum.GetValues() + .Where(x => x != CivitBaseModelType.All) + .Select(x => x.GetStringValue()) + ); + CheckpointFiles.CollectionChanged += OnCheckpointFilesChanged; // DisplayedCheckpointFiles = CheckpointFiles; } + private bool BaseModelFilter(CheckpointFile file) + { + return file.IsConnectedModel + ? BaseModelOptions.Contains(file.ConnectedModel!.BaseModel) + : BaseModelOptions.Contains("Other"); + } + + private bool ContainsSearchFilter(CheckpointFile file) + { + ArgumentNullException.ThrowIfNull(file); + + if (string.IsNullOrWhiteSpace(SearchFilter)) + { + return true; + } + + // Check files in the current folder + return file.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + || file.Title.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + || file.ConnectedModel?.ModelName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + == true + || file.ConnectedModel?.Tags.Any( + t => t.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + ) == true + || file.ConnectedModel?.TrainedWordsString.Contains( + SearchFilter, + StringComparison.OrdinalIgnoreCase + ) == true; + } + /// /// When title is set, set the category enabled state from settings. /// @@ -174,7 +228,7 @@ partial void OnTitleChanged(string value) var result = Enum.TryParse(Title, out SharedFolderType type); FolderType = result ? type : new SharedFolderType(); - IsCategoryEnabled = settingsManager.IsSharedFolderCategoryVisible(FolderType); + IsCategoryEnabled = settingsManager.Settings.SharedFolderVisibleCategories.HasFlag(FolderType); } partial void OnSearchFilterChanged(string value) @@ -194,10 +248,16 @@ partial void OnIsCategoryEnabledChanged(bool value) { if (!useCategoryVisibility) return; - if (value != settingsManager.IsSharedFolderCategoryVisible(FolderType)) + + if (value == settingsManager.Settings.SharedFolderVisibleCategories.HasFlag(FolderType)) + return; + + settingsManager.Transaction(settings => { - settingsManager.SetSharedFolderCategoryVisible(FolderType, value); - } + settings.SharedFolderVisibleCategories = value + ? settings.SharedFolderVisibleCategories | FolderType + : settings.SharedFolderVisibleCategories & ~FolderType; + }); } private void OnCheckpointFilesChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -393,7 +453,9 @@ public async Task ImportFilesAsync(IEnumerable files, bool convertToConn Progress.Value = report.Percentage; // For multiple files, add count Progress.Text = - copyPaths.Count > 1 ? $"Importing {report.Title} ({report.Message})" : $"Importing {report.Title}"; + copyPaths.Count > 1 + ? $"Importing {report.Title} ({report.Message})" + : $"Importing {report.Title}"; }); await FileTransfers.CopyFiles(copyPaths, progress); @@ -603,15 +665,11 @@ public void Index() SubFoldersCache.EditDiff(updatedFolders, (a, b) => a.Title == b.Title); // Index files - Dispatcher - .UIThread - .Post( - () => - { - var files = GetCheckpointFiles(); - checkpointFilesCache.EditDiff(files, CheckpointFile.FilePathComparer); - }, - DispatcherPriority.Background - ); + var files = GetCheckpointFiles(); + + Dispatcher.UIThread.Post( + () => checkpointFilesCache.EditDiff(files, CheckpointFile.FilePathComparer), + DispatcherPriority.Background + ); } } diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs index efd84bf9c..f5bc68b9d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Linq; @@ -12,17 +13,20 @@ using DynamicData.Binding; using FluentAvalonia.UI.Controls; using NLog; +using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; using TeachingTip = StabilityMatrix.Core.Models.Settings.TeachingTip; namespace StabilityMatrix.Avalonia.ViewModels; @@ -41,7 +45,19 @@ public partial class CheckpointsPageViewModel : PageViewModelBase public override string Title => "Checkpoints"; - public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Notebook, IsFilled = true }; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Notebook, IsFilled = true }; + + [ObservableProperty] + private ObservableCollection baseModelOptions = + new( + Enum.GetValues() + .Where(x => x != CivitBaseModelType.All) + .Select(x => x.GetStringValue()) + ); + + [ObservableProperty] + private ObservableCollection selectedBaseModels = []; // Toggle button for auto hashing new drag-and-dropped files for connected upgrade [ObservableProperty] @@ -81,6 +97,13 @@ partial void OnIsImportAsConnectedChanged(bool value) public IObservableCollection DisplayedCheckpointFolders { get; } = new ObservableCollectionExtended(); + public string ClearButtonText => + SelectedBaseModels.Count == BaseModelOptions.Count + ? Resources.Action_ClearSelection + : Resources.Action_SelectAll; + + private bool isClearing = false; + public CheckpointsPageViewModel( ISharedFolders sharedFolders, ISettingsManager settingsManager, @@ -97,12 +120,31 @@ ModelFinder modelFinder this.metadataImportService = metadataImportService; this.modelFinder = modelFinder; + SelectedBaseModels = new ObservableCollection(BaseModelOptions); + SelectedBaseModels.CollectionChanged += (_, _) => + { + foreach (var folder in CheckpointFolders) + { + folder.BaseModelOptionsCache.EditDiff(SelectedBaseModels); + } + + CheckpointFoldersCache.Refresh(); + OnPropertyChanged(nameof(ClearButtonText)); + if (!isClearing) + { + settingsManager.Transaction( + settings => settings.SelectedBaseModels = SelectedBaseModels.ToList() + ); + } + }; + CheckpointFoldersCache .Connect() .DeferUntilLoaded() - .SortBy(x => x.Title) .Bind(CheckpointFolders) .Filter(ContainsSearchFilter) + .Filter(ContainsBaseModel) + .SortBy(x => x.Title) .Bind(DisplayedCheckpointFolders) .Subscribe(); } @@ -110,9 +152,7 @@ ModelFinder modelFinder public override void OnLoaded() { base.OnLoaded(); - var sw = Stopwatch.StartNew(); - // DisplayedCheckpointFolders = CheckpointFolders; // Set UI states IsImportAsConnected = settingsManager.Settings.IsImportAsConnected; @@ -131,11 +171,16 @@ public override void OnLoaded() IsLoading = CheckpointFolders.Count == 0; IsIndexing = CheckpointFolders.Count > 0; - // GetStuff(); IndexFolders(); IsLoading = false; IsIndexing = false; + isClearing = true; + SelectedBaseModels.Clear(); + isClearing = false; + + SelectedBaseModels.AddRange(settingsManager.Settings.SelectedBaseModels); + Logger.Info($"OnLoadedAsync in {sw.ElapsedMilliseconds}ms"); } @@ -144,6 +189,19 @@ public void ClearSearchQuery() SearchFilter = string.Empty; } + public void ClearOrSelectAllBaseModels() + { + if (SelectedBaseModels.Count == BaseModelOptions.Count) + { + SelectedBaseModels.Clear(); + } + else + { + SelectedBaseModels.Clear(); + SelectedBaseModels.AddRange(BaseModelOptions); + } + } + // ReSharper disable once UnusedParameterInPartialMethod partial void OnSearchFilterChanged(string value) { @@ -165,8 +223,7 @@ partial void OnShowConnectedModelImagesChanged(bool value) private bool ContainsSearchFilter(CheckpointFolder folder) { - if (folder == null) - throw new ArgumentNullException(nameof(folder)); + ArgumentNullException.ThrowIfNull(folder); if (string.IsNullOrWhiteSpace(SearchFilter)) { @@ -174,12 +231,43 @@ private bool ContainsSearchFilter(CheckpointFolder folder) } // Check files in the current folder - return folder.CheckpointFiles.Any(x => x.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)) + return folder.CheckpointFiles.Any( + x => + x.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + || x.Title.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + || x.ConnectedModel?.ModelName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + == true + || x.ConnectedModel?.Tags.Any( + t => t.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + ) == true + || x.ConnectedModel?.TrainedWordsString.Contains( + SearchFilter, + StringComparison.OrdinalIgnoreCase + ) == true + ) || // If no matching files were found in the current folder, check in all subfolders folder.SubFolders.Any(ContainsSearchFilter); } + private bool ContainsBaseModel(CheckpointFolder folder) + { + ArgumentNullException.ThrowIfNull(folder); + + if (SelectedBaseModels.Count == 0 || SelectedBaseModels.Count == BaseModelOptions.Count) + return true; + + if (!folder.DisplayedCheckpointFiles.Any()) + return true; + + return folder.CheckpointFiles.Any( + x => + x.IsConnectedModel + ? SelectedBaseModels.Contains(x.ConnectedModel?.BaseModel) + : SelectedBaseModels.Contains("Other") + ) || folder.SubFolders.Any(ContainsBaseModel); + } + private void IndexFolders() { var modelsDirectory = settingsManager.ModelsDirectory; @@ -241,9 +329,16 @@ private async Task FindConnectedMetadata() Progress = report; }); - await metadataImportService.ScanDirectoryForMissingInfo(settingsManager.ModelsDirectory, progressHandler); + await metadataImportService.ScanDirectoryForMissingInfo( + settingsManager.ModelsDirectory, + progressHandler + ); - notificationService.Show("Scan Complete", "Finished scanning for missing metadata.", NotificationType.Success); + notificationService.Show( + "Scan Complete", + "Finished scanning for missing metadata.", + NotificationType.Success + ); DelayedClearProgress(TimeSpan.FromSeconds(1.5)); } diff --git a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs index 34253d7be..f68934004 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs @@ -7,9 +7,9 @@ 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/ExceptionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs index 42e8a6d65..9531c8641 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs @@ -1,4 +1,5 @@ using System; +using Sentry; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; @@ -12,7 +13,41 @@ public partial class ExceptionViewModel : ViewModelBase { public Exception? Exception { get; set; } + public SentryId? SentryId { get; set; } + public string? Message => Exception?.Message; public string? ExceptionType => Exception?.GetType().Name ?? ""; + + public string? FormatAsMarkdown() + { + if (Exception is null) + { + return null; + } + + var message = $"## Exception\n{ExceptionType}: {Message}\n"; + + if (SentryId is not null) + { + message += $"### Sentry ID\n```\n{SentryId}\n```\n"; + } + + if (Exception.StackTrace != null) + { + message += $"### Stack Trace\n```\n{Exception.StackTrace}\n```\n"; + } + + if (Exception.InnerException is { } innerException) + { + message += $"## Inner Exception\n{innerException.GetType().Name}: {innerException.Message}\n"; + + if (innerException.StackTrace != null) + { + message += $"### Stack Trace\n```\n{innerException.StackTrace}\n```\n"; + } + } + + return message; + } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs index aad4cc87c..fafd12065 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs @@ -8,6 +8,7 @@ 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 @@ IPackageFactory packageFactory 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 @@ private void NavigateToInstall() { 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 BetterContentDialog CreateDialog() 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 c98d41bf7..ef4565b97 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs @@ -20,7 +20,9 @@ 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.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; @@ -34,9 +36,7 @@ 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 +46,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 +92,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 +104,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 +140,7 @@ ILogger logger AvailablePackages = new ObservableCollection( filtered.Any() ? filtered : packageFactory.GetAllAvailablePackages() ); + SelectedPackage = AvailablePackages.FirstOrDefault(); ShowIncompatiblePackages = !filtered.Any(); } @@ -207,7 +200,7 @@ private async Task Install() ); if (result.IsSuccessful) { - OnPrimaryButtonClick(); + // OnPrimaryButtonClick(); } else { @@ -247,17 +240,15 @@ private async Task ActuallyInstall() await installPath.DeleteVerboseAsync(logger); } - var prereqStep = new SetupPrerequisitesStep(prerequisiteHelper, pyRunner); + var prereqStep = new SetupPrerequisitesStep(prerequisiteHelper, pyRunner, SelectedPackage); 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; + 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 +259,15 @@ private async Task ActuallyInstall() 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 +311,7 @@ private async Task ActuallyInstall() public void Cancel() { - OnCloseButtonClick(); + // OnCloseButtonClick(); } partial void OnShowIncompatiblePackagesChanged(bool value) @@ -351,9 +336,7 @@ private void UpdateSelectedVersionToLatestMain() 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 +389,8 @@ partial void OnSelectedVersionTypeChanged(PackageVersionType value) 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 +408,7 @@ partial void OnSelectedVersionTypeChanged(PackageVersionType value) 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 +487,6 @@ partial void OnSelectedVersionChanged(PackageVersion? value) .SafeFireAndForget(); } } + + public override IconSource IconSource { get; } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs index 17740c40a..73922420f 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/NewOneClickInstallViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/NewOneClickInstallViewModel.cs new file mode 100644 index 000000000..197c17daf --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/NewOneClickInstallViewModel.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DynamicData; +using DynamicData.Binding; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Extensions; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Factory; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +[Transient] +[ManagedService] +public partial class NewOneClickInstallViewModel : ContentDialogViewModelBase +{ + private readonly IPackageFactory packageFactory; + private readonly ISettingsManager settingsManager; + private readonly IPrerequisiteHelper prerequisiteHelper; + private readonly ILogger logger; + private readonly IPyRunner pyRunner; + private readonly INavigationService navigationService; + private readonly INotificationService notificationService; + + public SourceCache AllPackagesCache { get; } = new(p => p.Author + p.Name); + + public IObservableCollection ShownPackages { get; set; } = + new ObservableCollectionExtended(); + + [ObservableProperty] + private bool showIncompatiblePackages; + + private bool isInferenceInstall; + + public NewOneClickInstallViewModel( + IPackageFactory packageFactory, + ISettingsManager settingsManager, + IPrerequisiteHelper prerequisiteHelper, + ILogger logger, + IPyRunner pyRunner, + INavigationService navigationService, + INotificationService notificationService + ) + { + this.packageFactory = packageFactory; + this.settingsManager = settingsManager; + this.prerequisiteHelper = prerequisiteHelper; + this.logger = logger; + this.pyRunner = pyRunner; + this.navigationService = navigationService; + this.notificationService = notificationService; + + var incompatiblePredicate = this.WhenPropertyChanged(vm => vm.ShowIncompatiblePackages) + .Select(_ => new Func(p => p.IsCompatible || ShowIncompatiblePackages)) + .AsObservable(); + + AllPackagesCache + .Connect() + .DeferUntilLoaded() + .Filter(incompatiblePredicate) + .Filter(p => p.OfferInOneClickInstaller || ShowIncompatiblePackages) + .Sort( + SortExpressionComparer + .Ascending(p => p.InstallerSortOrder) + .ThenByAscending(p => p.DisplayName) + ) + .Bind(ShownPackages) + .Subscribe(); + + AllPackagesCache.AddOrUpdate(packageFactory.GetAllAvailablePackages()); + } + + [RelayCommand] + private void InstallComfyForInference() + { + var comfyPackage = ShownPackages.FirstOrDefault(x => x is ComfyUI); + if (comfyPackage == null) + return; + + isInferenceInstall = true; + InstallPackage(comfyPackage); + } + + [RelayCommand] + private void InstallPackage(BasePackage selectedPackage) + { + Task.Run(async () => + { + var steps = new List + { + new SetPackageInstallingStep(settingsManager, selectedPackage.Name), + new SetupPrerequisitesStep(prerequisiteHelper, pyRunner, selectedPackage) + }; + + // get latest version & download & install + var installLocation = Path.Combine( + settingsManager.LibraryDir, + "Packages", + selectedPackage.Name + ); + if (Directory.Exists(installLocation)) + { + var installPath = new DirectoryPath(installLocation); + await installPath.DeleteVerboseAsync(logger); + } + + var downloadVersion = await selectedPackage.GetLatestVersion(); + var installedVersion = new InstalledPackageVersion { IsPrerelease = false }; + + if (selectedPackage.ShouldIgnoreReleases) + { + installedVersion.InstalledBranch = downloadVersion.BranchName; + installedVersion.InstalledCommitSha = downloadVersion.CommitHash; + } + else + { + installedVersion.InstalledReleaseVersion = downloadVersion.VersionTag; + } + + var torchVersion = selectedPackage.GetRecommendedTorchVersion(); + var recommendedSharedFolderMethod = selectedPackage.RecommendedSharedFolderMethod; + + var downloadStep = new DownloadPackageVersionStep( + selectedPackage, + installLocation, + downloadVersion + ); + steps.Add(downloadStep); + + var installStep = new InstallPackageStep( + selectedPackage, + torchVersion, + recommendedSharedFolderMethod, + downloadVersion, + installLocation + ); + steps.Add(installStep); + + var setupModelFoldersStep = new SetupModelFoldersStep( + selectedPackage, + recommendedSharedFolderMethod, + installLocation + ); + steps.Add(setupModelFoldersStep); + + var installedPackage = new InstalledPackage + { + DisplayName = selectedPackage.DisplayName, + LibraryPath = Path.Combine("Packages", selectedPackage.Name), + Id = Guid.NewGuid(), + PackageName = selectedPackage.Name, + Version = installedVersion, + LaunchCommand = selectedPackage.LaunchCommand, + LastUpdateCheck = DateTimeOffset.Now, + PreferredTorchVersion = torchVersion, + PreferredSharedFolderMethod = recommendedSharedFolderMethod + }; + + var addInstalledPackageStep = new AddInstalledPackageStep(settingsManager, installedPackage); + steps.Add(addInstalledPackageStep); + + Dispatcher.UIThread.Post(() => + { + var runner = new PackageModificationRunner + { + ShowDialogOnStart = false, + HideCloseButton = false, + }; + + runner + .ExecuteSteps(steps) + .ContinueWith(_ => + { + notificationService.OnPackageInstallCompleted(runner); + + EventManager.Instance.OnOneClickInstallFinished(false); + + if (!isInferenceInstall) + return; + + Dispatcher.UIThread.Post(() => + { + navigationService.NavigateTo(); + }); + }) + .SafeFireAndForget(); + + EventManager.Instance.OnPackageInstallProgressAdded(runner); + }); + }) + .SafeFireAndForget(); + + OnPrimaryButtonClick(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs index e18e710ef..0e28b8fdc 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs @@ -12,6 +12,7 @@ using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; @@ -87,15 +88,12 @@ INavigationService navigationService SubHeaderText = Resources.Text_OneClickInstaller_SubHeader; ShowInstallButton = true; - var filteredPackages = this.packageFactory - .GetAllAvailablePackages() + var filteredPackages = this.packageFactory.GetAllAvailablePackages() .Where(p => p is { OfferInOneClickInstaller: true, IsCompatible: true }) .ToList(); AllPackages = new ObservableCollection( - filteredPackages.Any() - ? filteredPackages - : this.packageFactory.GetAllAvailablePackages() + filteredPackages.Any() ? filteredPackages : this.packageFactory.GetAllAvailablePackages() ); SelectedPackage = AllPackages[0]; } @@ -132,15 +130,11 @@ private async Task DoInstall() var steps = new List { new SetPackageInstallingStep(settingsManager, SelectedPackage.Name), - new SetupPrerequisitesStep(prerequisiteHelper, pyRunner) + new SetupPrerequisitesStep(prerequisiteHelper, pyRunner, SelectedPackage) }; // get latest version & download & install - var installLocation = Path.Combine( - settingsManager.LibraryDir, - "Packages", - SelectedPackage.Name - ); + var installLocation = Path.Combine(settingsManager.LibraryDir, "Packages", SelectedPackage.Name); if (Directory.Exists(installLocation)) { var installPath = new DirectoryPath(installLocation); @@ -163,11 +157,7 @@ private async Task DoInstall() var torchVersion = SelectedPackage.GetRecommendedTorchVersion(); var recommendedSharedFolderMethod = SelectedPackage.RecommendedSharedFolderMethod; - var downloadStep = new DownloadPackageVersionStep( - SelectedPackage, - installLocation, - downloadVersion - ); + var downloadStep = new DownloadPackageVersionStep(SelectedPackage, installLocation, downloadVersion); steps.Add(downloadStep); var installStep = new InstallPackageStep( @@ -199,17 +189,10 @@ private async Task DoInstall() PreferredSharedFolderMethod = recommendedSharedFolderMethod }; - var addInstalledPackageStep = new AddInstalledPackageStep( - settingsManager, - installedPackage - ); + var addInstalledPackageStep = new AddInstalledPackageStep(settingsManager, installedPackage); steps.Add(addInstalledPackageStep); - var runner = new PackageModificationRunner - { - ShowDialogOnStart = true, - HideCloseButton = true, - }; + var runner = new PackageModificationRunner { ShowDialogOnStart = true, HideCloseButton = true, }; EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps(steps); diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PropertyGridViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PropertyGridViewModel.cs index 757aee155..ba302e9a6 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PropertyGridViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PropertyGridViewModel.cs @@ -3,6 +3,7 @@ using Avalonia; using Avalonia.PropertyGrid.ViewModels; using CommunityToolkit.Mvvm.ComponentModel; +using FluentAvalonia.UI.Controls; using OneOf; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Languages; @@ -44,4 +45,20 @@ public override BetterContentDialog GetDialog() return dialog; } + + /// + /// Like , but with a primary save button. + /// + public BetterContentDialog GetSaveDialog() + { + var dialog = base.GetDialog(); + + dialog.Padding = new Thickness(0); + dialog.CloseOnClickOutside = true; + dialog.CloseButtonText = Resources.Action_Close; + dialog.PrimaryButtonText = Resources.Action_Save; + dialog.DefaultButton = ContentDialogButton.Primary; + + return dialog; + } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs index 7259df548..a31e7cf2c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesItemViewModel.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; @@ -49,8 +50,9 @@ value is null || !SemVersion.TryParse(value, out var selectedSemver) ) { - CanUpgrade = false; - CanDowngrade = false; + var compare = string.CompareOrdinal(value, Package.Version); + CanUpgrade = compare > 0; + CanDowngrade = compare < 0; return; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/RecommendedModelItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/RecommendedModelItemViewModel.cs new file mode 100644 index 000000000..3de1d5daa --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/RecommendedModelItemViewModel.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Models.Api; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +public partial class RecommendedModelItemViewModel : ViewModelBase +{ + [ObservableProperty] + private bool isSelected; + + [ObservableProperty] + private string author; + + [ObservableProperty] + private CivitModelVersion modelVersion; + + [ObservableProperty] + private CivitModel civitModel; + + public Uri ThumbnailUrl => + ModelVersion.Images?.FirstOrDefault()?.Url == null + ? Assets.NoImage + : new Uri(ModelVersion.Images.First().Url); + + [RelayCommand] + public void ToggleSelection() => IsSelected = !IsSelected; +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/RecommendedModelsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/RecommendedModelsViewModel.cs new file mode 100644 index 000000000..3ae964a49 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/RecommendedModelsViewModel.cs @@ -0,0 +1,209 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DynamicData; +using DynamicData.Binding; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Api; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Database; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +[Transient] +[ManagedService] +public partial class RecommendedModelsViewModel : ContentDialogViewModelBase +{ + private readonly ILogger logger; + private readonly ILykosAuthApi lykosApi; + private readonly ICivitApi civitApi; + private readonly ILiteDbContext liteDbContext; + private readonly ISettingsManager settingsManager; + private readonly INotificationService notificationService; + private readonly ITrackedDownloadService trackedDownloadService; + private readonly IDownloadService downloadService; + public SourceCache CivitModels { get; } = new(p => p.ModelVersion.Id); + + public IObservableCollection Sd15Models { get; set; } = + new ObservableCollectionExtended(); + + public IObservableCollection SdxlModels { get; } = + new ObservableCollectionExtended(); + + [ObservableProperty] + private bool isLoading; + + public RecommendedModelsViewModel( + ILogger logger, + ILykosAuthApi lykosApi, + ICivitApi civitApi, + ILiteDbContext liteDbContext, + ISettingsManager settingsManager, + INotificationService notificationService, + ITrackedDownloadService trackedDownloadService, + IDownloadService downloadService + ) + { + this.logger = logger; + this.lykosApi = lykosApi; + this.civitApi = civitApi; + this.liteDbContext = liteDbContext; + this.settingsManager = settingsManager; + this.notificationService = notificationService; + this.trackedDownloadService = trackedDownloadService; + this.downloadService = downloadService; + + CivitModels + .Connect() + .DeferUntilLoaded() + .Filter(f => f.ModelVersion.BaseModel == "SD 1.5") + .Bind(Sd15Models) + .Subscribe(); + + CivitModels + .Connect() + .DeferUntilLoaded() + .Filter(f => f.ModelVersion.BaseModel == "SDXL 1.0") + .Bind(SdxlModels) + .Subscribe(); + } + + public override async Task OnLoadedAsync() + { + if (Design.IsDesignMode) + return; + + IsLoading = true; + + var recommendedModels = await lykosApi.GetRecommendedModels(); + + CivitModels.AddOrUpdate( + recommendedModels.Items.Select( + model => + new RecommendedModelItemViewModel + { + ModelVersion = model.ModelVersions.First( + x => !x.BaseModel.Contains("Turbo", StringComparison.OrdinalIgnoreCase) + ), + Author = $"by {model.Creator.Username}", + CivitModel = model + } + ) + ); + + IsLoading = false; + } + + [RelayCommand] + private async Task DoImport() + { + var selectedModels = SdxlModels.Where(x => x.IsSelected).Concat(Sd15Models.Where(x => x.IsSelected)); + + foreach (var model in selectedModels) + { + // Get latest version file + var modelFile = model.ModelVersion.Files?.FirstOrDefault( + x => x is { Type: CivitFileType.Model, IsPrimary: true } + ); + if (modelFile is null) + { + continue; + } + + var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory); + + var downloadDirectory = rootModelsDirectory.JoinDir( + model.CivitModel.Type.ConvertTo().GetStringValue() + ); + // Folders might be missing if user didn't install any packages yet + downloadDirectory.Create(); + + var downloadPath = downloadDirectory.JoinFile(modelFile.Name); + + // Download model info and preview first + var cmInfoPath = await SaveCmInfo( + model.CivitModel, + model.ModelVersion, + modelFile, + downloadDirectory + ); + var previewImagePath = await SavePreviewImage(model.ModelVersion, downloadPath); + + // Create tracked download + var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath); + + // Add hash info + download.ExpectedHashSha256 = modelFile.Hashes.SHA256; + + // Add files to cleanup list + download.ExtraCleanupFileNames.Add(cmInfoPath); + if (previewImagePath is not null) + { + download.ExtraCleanupFileNames.Add(previewImagePath); + } + + // Add hash context action + download.ContextAction = CivitPostDownloadContextAction.FromCivitFile(modelFile); + + download.Start(); + } + } + + private static async Task SaveCmInfo( + CivitModel model, + CivitModelVersion modelVersion, + CivitFile modelFile, + DirectoryPath downloadDirectory + ) + { + var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name); + var modelInfo = new ConnectedModelInfo(model, modelVersion, modelFile, DateTime.UtcNow); + + await modelInfo.SaveJsonToDirectory(downloadDirectory, modelFileName); + + var jsonName = $"{modelFileName}.cm-info.json"; + return downloadDirectory.JoinFile(jsonName); + } + + /// + /// Saves the preview image to the same directory as the model file + /// + /// + /// + /// The file path of the saved preview image + private async Task SavePreviewImage(CivitModelVersion modelVersion, FilePath modelFilePath) + { + // Skip if model has no images + if (modelVersion.Images == null || modelVersion.Images.Count == 0) + { + return null; + } + + var image = modelVersion.Images[0]; + 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 imageTask = downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); + await notificationService.TryAsync(imageTask, "Could not download preview image"); + + return imageDownloadPath; + } + + return null; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs index 9db2e0ec1..e882049cf 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs @@ -66,8 +66,6 @@ public partial class SelectDataDirectoryViewModel : ContentDialogViewModelBase FailToolTipText = InvalidDirectoryText }; - public bool HasOldData => settingsManager.GetOldInstalledPackages().Any(); - public SelectDataDirectoryViewModel(ISettingsManager settingsManager) { this.settingsManager = settingsManager; diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs index b5cb4be9e..ef7c2ddf6 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 @@ partial void OnSelectedVersionViewModelChanged(ModelVersionViewModel? value) partial void OnSelectedFileChanged(CivitFileViewModel? value) { + if (value is { IsInstalled: true }) { } + var canImport = true; if (settingsManager.IsLibraryDirSet) { @@ -133,6 +151,90 @@ public void Import() 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 5cab55c20..13d84d896 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -18,6 +18,7 @@ using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Update; +using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Updater; @@ -126,10 +127,7 @@ private async Task InstallUpdate() ShowProgressBar = true; IsProgressIndeterminate = true; - UpdateText = string.Format( - Resources.TextTemplate_UpdatingPackage, - Resources.Label_StabilityMatrix - ); + UpdateText = string.Format(Resources.TextTemplate_UpdatingPackage, Resources.Label_StabilityMatrix); try { @@ -159,7 +157,8 @@ await updateHelper.DownloadUpdate( if (Compat.IsUnix) { File.SetUnixFileMode( - UpdateHelper.ExecutablePath, // 0755 + UpdateHelper.ExecutablePath.FullPath, + // 0755 UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute @@ -173,10 +172,13 @@ await updateHelper.DownloadUpdate( UpdateText = "Getting a few things ready..."; await using (new MinimumDelay(500, 1000)) { - Process.Start( - UpdateHelper.ExecutablePath, - $"--wait-for-exit-pid {Environment.ProcessId}" - ); + await Task.Run(() => + { + ProcessRunner.StartApp( + UpdateHelper.ExecutablePath.FullPath, + new[] { "--wait-for-exit-pid", $"{Environment.ProcessId}" } + ); + }); } UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; @@ -189,7 +191,7 @@ await updateHelper.DownloadUpdate( App.Shutdown(); } - + internal async Task GetReleaseNotes(string changelogUrl) { using var client = httpClientFactory.CreateClient(); @@ -262,9 +264,7 @@ out var version // 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 ( diff --git a/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs new file mode 100644 index 000000000..d1a7660fe --- /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 PackageExtension PackageExtension { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs index 72c47db16..46815f625 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 FirstLaunchSetupViewModel() 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/HuggingFacePage/CategoryViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/CategoryViewModel.cs index 12216cdd5..755153f70 100644 --- a/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/CategoryViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/CategoryViewModel.cs @@ -15,7 +15,8 @@ public partial class CategoryViewModel : ViewModelBase private IObservableCollection items = new ObservableCollectionExtended(); - public SourceCache ItemsCache { get; } = new(i => i.RepositoryPath + i.ModelName); + public SourceCache ItemsCache { get; } = + new(i => i.RepositoryPath + i.ModelName); [ObservableProperty] private string? title; @@ -26,12 +27,12 @@ public partial class CategoryViewModel : ViewModelBase [ObservableProperty] private int numSelected; - public CategoryViewModel(IEnumerable items) + public CategoryViewModel(IEnumerable items, string modelsDir) { ItemsCache .Connect() .DeferUntilLoaded() - .Transform(i => new HuggingfaceItemViewModel { Item = i }) + .Transform(i => new HuggingfaceItemViewModel { Item = i, ModelsDir = modelsDir }) .Bind(Items) .WhenPropertyChanged(p => p.IsSelected) .Subscribe(_ => NumSelected = Items.Count(i => i.IsSelected)); @@ -46,6 +47,9 @@ partial void OnIsCheckedChanged(bool value) foreach (var item in Items) { + if (item.Exists) + continue; + item.IsSelected = value; } } diff --git a/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/HuggingfaceItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/HuggingfaceItemViewModel.cs index 2fe59d6ac..a5e4ea0ef 100644 --- a/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/HuggingfaceItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/HuggingFacePage/HuggingfaceItemViewModel.cs @@ -1,7 +1,10 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using StabilityMatrix.Avalonia.Models.HuggingFace; using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models; namespace StabilityMatrix.Avalonia.ViewModels.HuggingFacePage; @@ -17,6 +20,18 @@ public partial class HuggingfaceItemViewModel : ViewModelBase $"https://huggingface.co/{Item.RepositoryPath}/blob/main/{Item.LicensePath ?? "README.md"}"; public string RepoUrl => $"https://huggingface.co/{Item.RepositoryPath}"; + public required string? ModelsDir { get; init; } + + public bool Exists => + File.Exists( + Path.Combine( + ModelsDir, + Item.ModelCategory.ConvertTo().ToString(), + Item.Subfolder ?? string.Empty, + Item.Files[0] + ) + ); + [RelayCommand] private void ToggleSelected() { diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs new file mode 100644 index 000000000..678b59473 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs @@ -0,0 +1,248 @@ +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 (!await ModelCardViewModel.ValidateModel()) + 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 4531d1b08..2ca1dbd81 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs @@ -107,9 +107,7 @@ protected override void BuildPrompt(BuildPromptEventArgs args) // 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 @@ protected override void BuildPrompt(BuildPromptEventArgs args) } /// - 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 @@ CancellationToken cancellationToken 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/InferenceTextToImageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs index 052986be9..9337eeb35 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs @@ -188,14 +188,13 @@ CancellationToken cancellationToken { // Validate the prompts if (!await PromptCardViewModel.ValidatePrompts()) - { return; - } + + if (!await ModelCardViewModel.ValidateModel()) + return; if (!await CheckClientConnectedWithPrompt() || !ClientManager.IsConnected) - { return; - } // If enabled, randomize the seed var seedCard = StackCardViewModel.GetCard(); diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs index 5336a3e1e..05d91e586 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs @@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.Json.Nodes; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models.Inference; using StabilityMatrix.Avalonia.Services; @@ -38,45 +40,67 @@ 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 async Task ValidateModel() + { + if (SelectedModel != null) + return true; + + var dialog = DialogHelper.CreateMarkdownDialog( + "Please select a model to continue.", + "No Model Selected" + ); + await dialog.ShowAsync(); + return false; + } + /// - 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 +115,28 @@ public void ApplyStep(ModuleApplyStepEventArgs e) 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 +148,10 @@ public override JsonObject SaveStateToJsonObject() SelectedModelName = SelectedModel?.RelativePath, SelectedVaeName = SelectedVae?.RelativePath, SelectedRefinerName = SelectedRefiner?.RelativePath, + ClipSkip = ClipSkip, IsVaeSelectionEnabled = IsVaeSelectionEnabled, - IsRefinerSelectionEnabled = IsRefinerSelectionEnabled + IsRefinerSelectionEnabled = IsRefinerSelectionEnabled, + IsClipSkipEnabled = IsClipSkipEnabled } ); } @@ -125,8 +173,11 @@ public override void LoadStateFromJsonObject(JsonObject state) ? HybridModelFile.None : ClientManager.Models.FirstOrDefault(x => x.RelativePath == model.SelectedRefinerName); + ClipSkip = model.ClipSkip; + IsVaeSelectionEnabled = model.IsVaeSelectionEnabled; IsRefinerSelectionEnabled = model.IsRefinerSelectionEnabled; + IsClipSkipEnabled = model.IsClipSkipEnabled; } /// @@ -145,19 +196,14 @@ public void LoadStateFromParameters(GenerationParameters parameters) 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 +227,10 @@ internal class ModelCardModel 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 9a6ad2229..23260daf3 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; @@ -26,7 +24,11 @@ public ControlNetModule(ServiceManager vmFactory) protected override IEnumerable GetInputImages() { - if (GetCard().SelectImageCardViewModel.ImageSource is { } image) + if ( + IsEnabled + && GetCard().SelectImageCardViewModel + is { ImageSource: { } image, IsImageFileNotFound: false } + ) { yield return image; } @@ -81,8 +83,8 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) 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 4ac393b6d..120933100 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 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) { 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 4fd98fea1..588cd263e 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 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) { 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 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) 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 bad20dba0..b6973534f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs @@ -18,6 +18,7 @@ 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 @@ SharedState sharedState /// Applies the prompt step. /// Requires: /// - /// - /// + /// - Model, Clip /// /// Provides: /// - /// - /// + /// - Conditioning /// ///
public void ApplyStep(ModuleApplyStepEventArgs e) @@ -91,90 +87,44 @@ public void ApplyStep(ModuleApplyStepEventArgs e) 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 @@ a red cat # also comments ``` """; - 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 @@ private async Task DebugShowTokens() } catch (PromptError e) { - await DialogHelper - .CreatePromptErrorDialog(e, prompt.RawText, modelIndexService) - .ShowAsync(); + await DialogHelper.CreatePromptErrorDialog(e, prompt.RawText, modelIndexService).ShowAsync(); return; } @@ -327,10 +271,7 @@ await DialogHelper 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 @@ private void EditorCut(TextEditor? textEditor) 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 void LoadStateFromParameters(GenerationParameters parameters) /// 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 a0d300a1a..96b9de362 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs @@ -12,6 +12,7 @@ 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 void ApplyStep(ModuleApplyStepEventArgs e) } // 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 @@ private void ApplyStepsInitialSampler(ModuleApplyStepEventArgs e) 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 @@ private void ApplyStepsInitialSampler(ModuleApplyStepEventArgs e) 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 @@ private void ApplyStepsInitialSampler(ModuleApplyStepEventArgs e) 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 @@ private void ApplyStepsInitialSampler(ModuleApplyStepEventArgs e) } // 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 @@ private void ApplyStepsInitialSampler(ModuleApplyStepEventArgs e) } 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 @@ private void ApplyStepsInitialSampler(ModuleApplyStepEventArgs e) 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/SelectImageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/SelectImageCardViewModel.cs index 7eecc919d..6e2e7b154 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/SelectImageCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/SelectImageCardViewModel.cs @@ -96,13 +96,21 @@ partial void OnImageSourceChanged(ImageSource? value) } } + private static FilePickerFileType SupportedImages { get; } = + new("Supported Images") + { + Patterns = new[] { "*.png", "*.jpg", "*.jpeg" }, + AppleUniformTypeIdentifiers = new[] { "public.jpeg", "public.png" }, + MimeTypes = new[] { "image/jpeg", "image/png" } + }; + [RelayCommand] private async Task SelectImageFromFilePickerAsync() { var files = await App.StorageProvider.OpenFilePickerAsync( new FilePickerOpenOptions { - FileTypeFilter = [FilePickerFileTypes.ImagePng, FilePickerFileTypes.ImageJpg] + FileTypeFilter = [FilePickerFileTypes.ImagePng, FilePickerFileTypes.ImageJpg, SupportedImages] } ); diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/StackEditableCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/StackEditableCardViewModel.cs index d26b646c1..c4da8f2ff 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/StackEditableCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/StackEditableCardViewModel.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Newtonsoft.Json; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/StackExpanderViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/StackExpanderViewModel.cs index e3ed89661..c188dbb95 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/StackExpanderViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/StackExpanderViewModel.cs @@ -1,14 +1,11 @@ -using System.Linq; -using System.Text.Json.Nodes; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Newtonsoft.Json; using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Models.Inference; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Extensions; #pragma warning disable CS0657 // Not a valid attribute location for this declaration namespace StabilityMatrix.Avalonia.ViewModels.Inference; diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs new file mode 100644 index 000000000..2bbf8021f --- /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 000000000..1bb70b269 --- /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 000000000..d44f469ae --- /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 e3966c1f8..c36fa1289 100644 --- a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs @@ -35,7 +35,7 @@ using InferenceTabViewModelBase = StabilityMatrix.Avalonia.ViewModels.Base.InferenceTabViewModelBase; using Path = System.IO.Path; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; @@ -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 @@ SharedState sharedState // "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 @@ private void OnRunningPackageStatusChanged(object? sender, RunningPackageStatusC 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 @@ protected override async Task OnInitialLoadedAsync() ); // 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 @@ private void OnInferenceUpscaleRequested(object? sender, LocalImageFile e) 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 @@ private async Task SyncTabStatesWithDatabase() 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 @@ private async Task SyncTabStateWithDatabase(InferenceTabViewModelBase tab) 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 @@ private async Task SyncTabStateWithDatabase(InferenceTabViewModelBase tab) 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 @@ private async Task Disconnect() 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 @@ private async Task MenuSaveAs() 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 @@ private async Task MenuSave() 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 @@ private async Task AddUpscalerTabFromImage(LocalImageFile imageFile) 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/LaunchPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs index b9ed79abc..8f6877e0c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs @@ -35,7 +35,7 @@ using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; @@ -58,8 +58,7 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable, IAsyn private static partial Regex InputYesNoRegex(); public override string Title => "Launch"; - public override IconSource IconSource => - new SymbolIconSource { Symbol = Symbol.Rocket, IsFilled = true }; + public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Rocket, IsFilled = true }; public ConsoleViewModel Console { get; } = new(); @@ -154,8 +153,7 @@ ServiceManager dialogFactory }; } - private void OnTeachingTooltipNeeded(object? sender, EventArgs e) => - IsLaunchTeachingTipsOpen = true; + private void OnTeachingTooltipNeeded(object? sender, EventArgs e) => IsLaunchTeachingTipsOpen = true; private void OnInstalledPackagesChanged(object? sender, EventArgs e) => OnLoaded(); @@ -297,7 +295,7 @@ await basePackage.UpdateModelFolders( ); // Load user launch args from settings and convert to string - var userArgs = settingsManager.GetLaunchArgs(activeInstall.Id); + var userArgs = activeInstall.LaunchArgs ?? []; var userArgsString = string.Join(" ", userArgs.Select(opt => opt.ToArgString())); // Join with extras, if any @@ -339,7 +337,6 @@ private async Task Config() return; } - var definitions = package.LaunchOptions; // Check if package supports IArgParsable // Use dynamic parsed args over static /*if (package is IArgParsable parsable) @@ -351,10 +348,9 @@ private async Task Config() }*/ // Open a config page - var userLaunchArgs = settingsManager.GetLaunchArgs(activeInstall.Id); var viewModel = dialogFactory.Get(); viewModel.Cards = LaunchOptionCard - .FromDefinitions(definitions, userLaunchArgs) + .FromDefinitions(package.LaunchOptions, activeInstall.LaunchArgs ?? []) .ToImmutableArray(); logger.LogDebug("Launching config dialog with cards: {CardsCount}", viewModel.Cards.Count); @@ -454,14 +450,10 @@ public void OpenWebUi() private void OnProcessExited(object? sender, int exitCode) { EventManager.Instance.OnRunningPackageStatusChanged(null); - Dispatcher.UIThread - .InvokeAsync(async () => + Dispatcher + .UIThread.InvokeAsync(async () => { - logger.LogTrace( - "Process exited ({Code}) at {Time:g}", - exitCode, - DateTimeOffset.Now - ); + logger.LogTrace("Process exited ({Code}) at {Time:g}", exitCode, DateTimeOffset.Now); // Need to wait for streams to finish before detaching handlers if (sender is BaseGitPackage { VenvRunner: not null } package) @@ -477,10 +469,7 @@ private void OnProcessExited(object? sender, int exitCode) } catch (OperationCanceledException e) { - logger.LogWarning( - "Waiting for process EOF timed out: {Message}", - e.Message - ); + logger.LogWarning("Waiting for process EOF timed out: {Message}", e.Message); } } } @@ -499,9 +488,7 @@ private void OnProcessExited(object? sender, int exitCode) // Need to reset cursor in case its in some weird position // from progress bars await Console.ResetWriteCursor(); - Console.PostLine( - $"{Environment.NewLine}Process finished with exit code {exitCode}" - ); + Console.PostLine($"{Environment.NewLine}Process finished with exit code {exitCode}"); }) .SafeFireAndForget(); } @@ -538,8 +525,8 @@ public void OnMainWindowClosing(WindowClosingEventArgs e) e.Cancel = true; var dialog = CreateExitConfirmDialog(); - Dispatcher.UIThread - .InvokeAsync(async () => + Dispatcher + .UIThread.InvokeAsync(async () => { if ( (TaskDialogStandardResult)await dialog.ShowAsync(true) diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs index 842df99e3..5aec56377 100644 --- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs @@ -12,6 +12,7 @@ using FluentAvalonia.UI.Controls; using NLog; using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; @@ -52,6 +53,19 @@ public partial class MainWindowViewModel : ViewModelBase public ProgressManagerViewModel ProgressManagerViewModel { get; init; } public UpdateViewModel UpdateViewModel { get; init; } + public double PaneWidth => + Cultures.Current switch + { + { Name: "it-IT" } => 250, + { Name: "fr-FR" } => 250, + { Name: "es" } => 250, + { Name: "ru-RU" } => 250, + { Name: "tr-TR" } => 235, + { Name: "de" } => 250, + { Name: "pt-PT" } => 300, + _ => 200 + }; + public MainWindowViewModel( ISettingsManager settingsManager, IDiscordRichPresenceService discordRichPresenceService, @@ -112,30 +126,49 @@ protected override async Task OnInitialLoadedAsync() 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 viewModel = dialogFactory.Get(); var dialog = new BetterContentDialog { IsPrimaryButtonEnabled = false, IsSecondaryButtonEnabled = false, IsFooterVisible = false, - Content = new OneClickInstallDialog { DataContext = viewModel }, + FullSizeDesired = true, + MinDialogHeight = 775, + Content = new NewOneClickInstallDialog { DataContext = viewModel }, }; EventManager.Instance.OneClickInstallFinished += (_, skipped) => { - dialog.Hide(); if (skipped) return; EventManager.Instance.OnTeachingTooltipNeeded(); }; + var firstDialogResult = await dialog.ShowAsync(App.TopLevel); + + if (firstDialogResult != ContentDialogResult.Primary) + return; + + var recommendedModelsViewModel = dialogFactory.Get(); + dialog = new BetterContentDialog + { + IsPrimaryButtonEnabled = true, + FullSizeDesired = true, + MinDialogHeight = 900, + PrimaryButtonText = Resources.Action_Download, + CloseButtonText = Resources.Action_Close, + DefaultButton = ContentDialogButton.Primary, + PrimaryButtonCommand = recommendedModelsViewModel.DoImportCommand, + Content = new RecommendedModelsDialog { DataContext = recommendedModelsViewModel }, + }; + await dialog.ShowAsync(App.TopLevel); + + EventManager.Instance.OnRecommendedModelsDialogClosed(); + EventManager.Instance.OnDownloadsTeachingTipRequested(); } } @@ -148,8 +181,8 @@ var page in Pages .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/NewCheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs index 87ebb0f5d..45ea5ed08 100644 --- a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -28,41 +29,28 @@ using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; [View(typeof(NewCheckpointsPage))] [Singleton] -public partial class NewCheckpointsPageViewModel : PageViewModelBase +public partial class NewCheckpointsPageViewModel( + ILogger logger, + ISettingsManager settingsManager, + ILiteDbContext liteDbContext, + ICivitApi civitApi, + ServiceManager dialogFactory, + INotificationService notificationService, + IDownloadService downloadService, + ModelFinder modelFinder, + IMetadataImportService metadataImportService +) : PageViewModelBase { - private readonly ILogger logger; - private readonly ISettingsManager settingsManager; - private readonly ILiteDbContext liteDbContext; - private readonly ICivitApi civitApi; - private readonly ServiceManager dialogFactory; - private readonly INotificationService notificationService; public override string Title => "Checkpoint Manager"; public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Cellular5g, IsFilled = true }; - public NewCheckpointsPageViewModel( - ILogger logger, - ISettingsManager settingsManager, - ILiteDbContext liteDbContext, - ICivitApi civitApi, - ServiceManager dialogFactory, - INotificationService notificationService - ) - { - this.logger = logger; - this.settingsManager = settingsManager; - this.liteDbContext = liteDbContext; - this.civitApi = civitApi; - this.dialogFactory = dialogFactory; - this.notificationService = notificationService; - } - [ObservableProperty] [NotifyPropertyChangedFor(nameof(ConnectedCheckpoints))] [NotifyPropertyChangedFor(nameof(NonConnectedCheckpoints))] @@ -89,7 +77,61 @@ public override async Task OnLoadedAsync() if (Design.IsDesignMode) return; - var files = CheckpointFile.GetAllCheckpointFiles(settingsManager.ModelsDirectory); + var files = CheckpointFile.GetAllCheckpointFiles(settingsManager.ModelsDirectory).ToList(); + + var uniqueSubFolders = files + .Select( + x => + x.FilePath.Replace(settingsManager.ModelsDirectory, string.Empty) + .Replace(x.FileName, string.Empty) + .Trim(Path.DirectorySeparatorChar) + ) + .Distinct() + .Where(x => x.Contains(Path.DirectorySeparatorChar)) + .Where(x => Directory.Exists(Path.Combine(settingsManager.ModelsDirectory, x))) + .ToList(); + + var checkpointFolders = Enum.GetValues() + .Where(x => Directory.Exists(Path.Combine(settingsManager.ModelsDirectory, x.ToString()))) + .Select( + folderType => + new CheckpointFolder( + settingsManager, + downloadService, + modelFinder, + notificationService, + metadataImportService + ) + { + Title = folderType.ToString(), + DirectoryPath = Path.Combine(settingsManager.ModelsDirectory, folderType.ToString()), + FolderType = folderType, + IsExpanded = true, + } + ) + .ToList(); + + foreach (var folder in uniqueSubFolders) + { + var folderType = Enum.Parse(folder.Split(Path.DirectorySeparatorChar)[0]); + var parentFolder = checkpointFolders.FirstOrDefault(x => x.FolderType == folderType); + var checkpointFolder = new CheckpointFolder( + settingsManager, + downloadService, + modelFinder, + notificationService, + metadataImportService + ) + { + Title = folderType.ToString(), + DirectoryPath = Path.Combine(settingsManager.ModelsDirectory, folder), + FolderType = folderType, + ParentFolder = parentFolder, + IsExpanded = true, + }; + parentFolder?.SubFolders.Add(checkpointFolder); + } + AllCheckpoints = new ObservableCollection(files); var connectedModelIds = ConnectedCheckpoints.Select(x => x.ConnectedModel.ModelId); @@ -99,8 +141,8 @@ public override async Task OnLoadedAsync() }; // See if query is cached - var cachedQuery = await liteDbContext.CivitModelQueryCache - .IncludeAll() + var cachedQuery = await liteDbContext + .CivitModelQueryCache.IncludeAll() .FindByIdAsync(ObjectHash.GetMd5Guid(modelRequest)); // If cached, update model cards diff --git a/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs new file mode 100644 index 000000000..d900f2bc0 --- /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.Avalonia.Fluent.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 b49ff6d12..ecb81a55a 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -39,7 +39,7 @@ using StabilityMatrix.Core.Services; using Size = StabilityMatrix.Core.Models.Settings.Size; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; @@ -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, @@ -134,8 +136,11 @@ ILogger logger settings => settings.OutputsImageSize, delay: TimeSpan.FromMilliseconds(250) ); + } - RefreshCategories(false); + protected override void OnInitialLoaded() + { + RefreshCategories(); } public override void OnLoaded() @@ -146,6 +151,8 @@ public override void OnLoaded() if (!settingsManager.IsLibraryDirSet) return; + base.OnLoaded(); + Directory.CreateDirectory(settingsManager.ImagesDirectory); SelectedCategory ??= Categories.First(); @@ -313,6 +320,18 @@ public void SendToUpscale(OutputImageViewModel vm) 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 +453,14 @@ public async Task ConsolidateImages() 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 +518,8 @@ private void GetOutputs(string directory) } 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(); @@ -510,7 +533,7 @@ private void GetOutputs(string directory) } } - private void RefreshCategories(bool updateProperty = true) + private void RefreshCategories() { if (Design.IsDesignMode) return; @@ -524,7 +547,10 @@ private void RefreshCategories(bool updateProperty = true) .Settings.InstalledPackages.Where(x => !x.UseSharedOutputFolder) .Select(packageFactory.GetPackagePair) .WhereNotNull() - .Where(p => p.BasePackage.SharedOutputFolders != null && p.BasePackage.SharedOutputFolders.Any()) + .Where( + p => + p.BasePackage.SharedOutputFolders is { Count: > 0 } && p.InstalledPackage.FullPath != null + ) .Select( pair => new PackageOutputCategory @@ -554,17 +580,7 @@ private void RefreshCategories(bool updateProperty = true) Categories = new ObservableCollection(packageCategories); - if (updateProperty) - { - SelectedCategory = - Categories.FirstOrDefault(x => x.Name == previouslySelectedCategory?.Name) - ?? Categories.First(); - } - else - { - selectedCategory = - Categories.FirstOrDefault(x => x.Name == previouslySelectedCategory?.Name) - ?? Categories.First(); - } + SelectedCategory = + Categories.FirstOrDefault(x => x.Name == previouslySelectedCategory?.Name) ?? Categories.First(); } } diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs index e6d1c6a8c..ba815fbf3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs @@ -2,13 +2,16 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Notifications; +using Avalonia.Controls.Primitives; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using FluentAvalonia.UI.Controls; using Microsoft.Extensions.Logging; using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; @@ -23,6 +26,7 @@ using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; @@ -75,6 +79,9 @@ public partial class PackageCardViewModel : ProgressViewModel [ObservableProperty] private bool canUseSharedOutput; + [ObservableProperty] + private bool canUseExtensions; + public PackageCardViewModel( ILogger logger, IPackageFactory packageFactory, @@ -97,7 +104,10 @@ partial void OnPackageChanged(InstalledPackage? value) 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 = ""; @@ -116,6 +126,7 @@ partial void OnPackageChanged(InstalledPackage? value) basePackage?.AvailableSharedFolderMethods.Contains(SharedFolderMethod.Symlink) ?? false; UseSharedOutput = Package?.UseSharedOutputFolder ?? false; CanUseSharedOutput = basePackage?.SharedOutputFolders != null; + CanUseExtensions = basePackage?.SupportsExtensions ?? false; } } @@ -124,7 +135,11 @@ public override async Task OnLoadedAsync() 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 +200,18 @@ public async Task Uninstall() 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) @@ -235,7 +257,13 @@ public async Task Update() { var runner = new PackageModificationRunner { - ModificationCompleteMessage = $"{packageName} Update Complete" + ModificationCompleteMessage = $"Updated {packageName}", + ModificationFailedMessage = $"Could not update {packageName}" + }; + + runner.Completed += (_, completedRunner) => + { + notificationService.OnPackageInstallCompleted(completedRunner); }; var versionOptions = new DownloadPackageVersionOptions { IsLatest = true }; @@ -254,7 +282,12 @@ public async Task Update() 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); @@ -356,6 +389,36 @@ public async Task OpenPythonPackagesDialog() await vm.GetDialog().ShowAsync(); } + [RelayCommand] + public async Task OpenExtensionsDialog() + { + if ( + Package is not { FullPath: not null } + || packageFactory.GetPackagePair(Package) is not { } packagePair + ) + return; + + var vm = vmFactory.Get(vm => + { + vm.PackagePair = packagePair; + }); + + var dialog = new BetterContentDialog + { + Content = vm, + MinDialogWidth = 850, + MaxDialogHeight = 1100, + MaxDialogWidth = 850, + ContentMargin = new Thickness(16, 32), + CloseOnClickOutside = true, + FullSizeDesired = true, + IsFooterVisible = false, + ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled + }; + + await dialog.ShowAsync(); + } + [RelayCommand] private void OpenOnGitHub() { @@ -381,7 +444,8 @@ private async Task HasUpdate() 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) { @@ -391,9 +455,13 @@ private async Task HasUpdate() try { var hasUpdate = await basePackage.CheckForUpdates(Package); - Package.UpdateAvailable = hasUpdate; - Package.LastUpdateCheck = DateTimeOffset.Now; - settingsManager.SetLastUpdateCheck(Package); + + await using (settingsManager.BeginTransaction()) + { + Package.UpdateAvailable = hasUpdate; + Package.LastUpdateCheck = DateTimeOffset.Now; + } + return hasUpdate; } catch (Exception e) diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageExtensionBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageExtensionBrowserViewModel.cs new file mode 100644 index 000000000..9aa2e6fc0 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageExtensionBrowserViewModel.cs @@ -0,0 +1,498 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DynamicData; +using DynamicData.Binding; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Collections; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.Views.PackageManager; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.PackageManager; + +[View(typeof(PackageExtensionBrowserView))] +[Transient] +[ManagedService] +public partial class PackageExtensionBrowserViewModel : ViewModelBase, IDisposable +{ + private readonly INotificationService notificationService; + private readonly ISettingsManager settingsManager; + private readonly ServiceManager vmFactory; + private readonly CompositeDisposable cleanUp; + + public PackagePair? PackagePair { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowNoExtensionsFoundMessage))] + private bool isLoading; + + private SourceCache availableExtensionsSource = + new(ext => ext.Author + ext.Title + ext.Reference); + + public IObservableCollection> SelectedAvailableItems { get; } = + new ObservableCollectionExtended>(); + + public SearchCollection< + SelectableItem, + string, + string + > AvailableItemsSearchCollection { get; } + + private SourceCache installedExtensionsSource = + new( + ext => + ext.Paths.FirstOrDefault()?.ToString() ?? ext.GitRepositoryUrl ?? ext.GetHashCode().ToString() + ); + + public IObservableCollection> SelectedInstalledItems { get; } = + new ObservableCollectionExtended>(); + + public SearchCollection< + SelectableItem, + string, + string + > InstalledItemsSearchCollection { get; } + + public IObservableCollection InstalledExtensions { get; } = + new ObservableCollectionExtended(); + + [ObservableProperty] + private bool showNoExtensionsFoundMessage; + + public PackageExtensionBrowserViewModel( + INotificationService notificationService, + ISettingsManager settingsManager, + ServiceManager vmFactory + ) + { + this.notificationService = notificationService; + this.settingsManager = settingsManager; + this.vmFactory = vmFactory; + + var availableItemsChangeSet = availableExtensionsSource + .Connect() + .Transform(ext => new SelectableItem(ext)) + .ObserveOn(SynchronizationContext.Current!) + .Publish(); + + availableItemsChangeSet + .AutoRefresh(item => item.IsSelected) + .Filter(item => item.IsSelected) + .Bind(SelectedAvailableItems) + .Subscribe(); + + var installedItemsChangeSet = installedExtensionsSource + .Connect() + .Transform(ext => new SelectableItem(ext)) + .ObserveOn(SynchronizationContext.Current!) + .Publish(); + + installedItemsChangeSet + .AutoRefresh(item => item.IsSelected) + .Filter(item => item.IsSelected) + .Bind(SelectedInstalledItems) + .Subscribe(); + + cleanUp = new CompositeDisposable( + AvailableItemsSearchCollection = new SearchCollection< + SelectableItem, + string, + string + >( + availableItemsChangeSet, + query => + string.IsNullOrWhiteSpace(query) + ? _ => true + : x => x.Item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) + ), + availableItemsChangeSet.Connect(), + InstalledItemsSearchCollection = new SearchCollection< + SelectableItem, + string, + string + >( + installedItemsChangeSet, + query => + string.IsNullOrWhiteSpace(query) + ? _ => true + : x => x.Item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) + ), + installedItemsChangeSet.Connect() + ); + } + + public void AddExtensions( + IEnumerable packageExtensions, + IEnumerable installedExtensions + ) + { + availableExtensionsSource.AddOrUpdate(packageExtensions); + installedExtensionsSource.AddOrUpdate(installedExtensions); + } + + [RelayCommand] + public async Task InstallSelectedExtensions() + { + if (!await BeforeInstallCheck()) + return; + + var extensions = SelectedAvailableItems + .Select(item => item.Item) + .Where(extension => !extension.IsInstalled) + .ToArray(); + + if (extensions.Length == 0) + return; + + var steps = extensions + .Select( + ext => + new InstallExtensionStep( + PackagePair!.BasePackage.ExtensionManager!, + PackagePair.InstalledPackage, + ext + ) + ) + .Cast() + .ToArray(); + + var runner = new PackageModificationRunner { ShowDialogOnStart = true }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + await runner.ExecuteSteps(steps); + + ClearSelection(); + + RefreshBackground(); + } + + [RelayCommand] + public async Task UpdateSelectedExtensions() + { + var extensions = SelectedInstalledItems.Select(x => x.Item).ToArray(); + + if (extensions.Length == 0) + return; + + var steps = extensions + .Select( + ext => + new UpdateExtensionStep( + PackagePair!.BasePackage.ExtensionManager!, + PackagePair.InstalledPackage, + ext + ) + ) + .Cast() + .ToArray(); + + var runner = new PackageModificationRunner { ShowDialogOnStart = true }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + await runner.ExecuteSteps(steps); + + ClearSelection(); + + RefreshBackground(); + } + + [RelayCommand] + public async Task UninstallSelectedExtensions() + { + var extensions = SelectedInstalledItems.Select(x => x.Item).ToArray(); + + if (extensions.Length == 0) + return; + + var steps = extensions + .Select( + ext => + new UninstallExtensionStep( + PackagePair!.BasePackage.ExtensionManager!, + PackagePair.InstalledPackage, + ext + ) + ) + .Cast() + .ToArray(); + + var runner = new PackageModificationRunner { ShowDialogOnStart = true }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + await runner.ExecuteSteps(steps); + + ClearSelection(); + + RefreshBackground(); + } + + [RelayCommand] + public async Task OpenExtensionsSettingsDialog() + { + if (PackagePair is null) + return; + + var grid = new ExtensionSettingsPropertyGrid + { + ManifestUrls = new BindingList( + PackagePair?.InstalledPackage.ExtraExtensionManifestUrls ?? [] + ) + }; + + var dialog = vmFactory + .Get(vm => + { + vm.Title = $"{Resources.Label_Settings}"; + vm.SelectedObject = grid; + vm.IncludeCategories = ["Base"]; + }) + .GetSaveDialog(); + + dialog.MinDialogWidth = 750; + dialog.MaxDialogWidth = 750; + + if (await dialog.ShowAsync() == ContentDialogResult.Primary) + { + await using var _ = settingsManager.BeginTransaction(); + + PackagePair!.InstalledPackage.ExtraExtensionManifestUrls = grid.ManifestUrls.ToList(); + } + } + + /// + public override async Task OnLoadedAsync() + { + await base.OnLoadedAsync(); + + await Refresh(); + } + + [RelayCommand] + public async Task Refresh() + { + if (PackagePair is null) + return; + + IsLoading = true; + + try + { + if (Design.IsDesignMode) + { + var (availableExtensions, installedExtensions) = SynchronizeExtensions( + availableExtensionsSource.Items, + installedExtensionsSource.Items + ); + + availableExtensionsSource.EditDiff(availableExtensions); + installedExtensionsSource.EditDiff(installedExtensions); + + await Task.Delay(250); + } + else + { + await RefreshCore(); + } + } + finally + { + IsLoading = false; + ShowNoExtensionsFoundMessage = AvailableItemsSearchCollection.FilteredItems.Count == 0; + } + } + + public void RefreshBackground() + { + RefreshCore() + .SafeFireAndForget(ex => + { + notificationService.ShowPersistent("Failed to refresh extensions", ex.ToString()); + }); + } + + private async Task RefreshCore() + { + using var _ = CodeTimer.StartDebug(); + + if (PackagePair?.BasePackage.ExtensionManager is not { } extensionManager) + { + throw new NotSupportedException( + $"The package {PackagePair?.BasePackage} does not support extensions." + ); + } + + var availableExtensions = ( + await extensionManager.GetManifestExtensionsAsync( + extensionManager.GetManifests(PackagePair.InstalledPackage) + ) + ).ToArray(); + + var installedExtensions = ( + await extensionManager.GetInstalledExtensionsAsync(PackagePair.InstalledPackage) + ).ToArray(); + + // Synchronize + SynchronizeExtensions(availableExtensions, installedExtensions); + + await Task.Run(() => + { + availableExtensionsSource.Edit(updater => + { + updater.Load(availableExtensions); + }); + + installedExtensionsSource.Edit(updater => + { + updater.Load(installedExtensions); + }); + }); + } + + public void ClearSelection() + { + foreach (var item in SelectedAvailableItems.ToImmutableArray()) + { + item.IsSelected = false; + } + + foreach (var item in SelectedInstalledItems.ToImmutableArray()) + { + item.IsSelected = false; + } + } + + private async Task BeforeInstallCheck() + { + if ( + !settingsManager.Settings.SeenTeachingTips.Contains( + Core.Models.Settings.TeachingTip.PackageExtensionsInstallNotice + ) + ) + { + var dialog = new BetterContentDialog + { + Title = "Installing Extensions", + Content = """ + Extensions, the extension index, and their dependencies are community provided and not verified by the Stability Matrix team. + + The install process may invoke external programs and scripts. + + Please review the extension's source code and applicable licenses before installing. + """, + PrimaryButtonText = Resources.Action_Continue, + CloseButtonText = Resources.Action_Cancel, + DefaultButton = ContentDialogButton.Primary, + MaxDialogWidth = 400 + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + { + return false; + } + + settingsManager.Transaction( + s => s.SeenTeachingTips.Add(Core.Models.Settings.TeachingTip.PackageExtensionsInstallNotice) + ); + } + + return true; + } + + [Pure] + private static ( + IEnumerable extensions, + IEnumerable installedExtensions + ) SynchronizeExtensions( + IEnumerable extensions, + IEnumerable installedExtensions + ) + { + var availableArr = extensions.ToArray(); + var installedArr = installedExtensions.ToArray(); + + SynchronizeExtensions(availableArr, installedArr); + + return (availableArr, installedArr); + } + + private static void SynchronizeExtensions( + IList extensions, + IList installedExtensions + ) + { + // For extensions, map their file paths for lookup + var repoToExtension = extensions + .SelectMany(ext => ext.Files.Select(path => (path, ext))) + .ToLookup(kv => kv.path.ToString().StripEnd(".git")) + .ToDictionary(group => group.Key, x => x.First().ext); + + // For installed extensions, add remote repo if available + var extensionsInstalled = new HashSet(); + + foreach (var (i, installedExt) in installedExtensions.Enumerate()) + { + if ( + installedExt.GitRepositoryUrl is not null + && repoToExtension.TryGetValue( + installedExt.GitRepositoryUrl.StripEnd(".git"), + out var mappedExt + ) + ) + { + extensionsInstalled.Add(mappedExt); + + installedExtensions[i] = installedExt with { Definition = mappedExt }; + } + } + + // For available extensions, add installed status if available + foreach (var (i, ext) in extensions.Enumerate()) + { + if (extensionsInstalled.Contains(ext)) + { + extensions[i] = ext with { IsInstalled = true }; + } + } + } + + /// + public void Dispose() + { + availableExtensionsSource.Dispose(); + installedExtensionsSource.Dispose(); + + cleanUp.Dispose(); + + GC.SuppressFinalize(this); + } + + private class ExtensionSettingsPropertyGrid : AbstractNotifyPropertyChanged + { + [Category("Base")] + [DisplayName("Extension Manifest Sources")] + public BindingList ManifestUrls { get; init; } = []; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs new file mode 100644 index 000000000..e38770ace --- /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 000000000..80d4caa90 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs @@ -0,0 +1,276 @@ +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.Extensions; +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.Avalonia.Fluent.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; + + [ObservableProperty] + private bool canInstall; + + private PackageVersionOptions? allOptions; + + public override async Task OnLoadedAsync() + { + if (Design.IsDesignMode) + return; + + OnInstallNameChanged(InstallName); + + SelectedTorchVersion = SelectedPackage.GetRecommendedTorchVersion(); + SelectedSharedFolderMethod = SelectedPackage.RecommendedSharedFolderMethod; + + allOptions = await SelectedPackage.GetAllVersionOptions(); + if (ShowReleaseMode) + { + IsReleaseMode = true; + } + else + { + UpdateVersions(); + await UpdateCommits(SelectedPackage.MainBranch); + } + + CanInstall = !ShowDuplicateWarning; + } + + [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, SelectedPackage); + + 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 packageName = SelectedPackage.Name; + + var runner = new PackageModificationRunner + { + ModificationCompleteMessage = $"Installed {packageName} at [{installLocation}]", + ModificationFailedMessage = $"Could not install {packageName}", + ShowDialogOnStart = true + }; + runner.Completed += (_, completedRunner) => + { + notificationService.OnPackageInstallCompleted(completedRunner); + }; + + EventManager.Instance.OnPackageInstallProgressAdded(runner); + await runner.ExecuteSteps(steps.ToList()); + + if (!runner.Failed) + { + EventManager.Instance.OnInstalledPackagesChanged(); + } + } + + private void UpdateVersions() + { + CanInstall = false; + + AvailableVersions = + IsReleaseMode && ShowReleaseMode ? allOptions.AvailableVersions : allOptions.AvailableBranches; + + SelectedVersion = !IsReleaseMode + ? AvailableVersions?.FirstOrDefault(x => x.TagName == SelectedPackage.MainBranch) + ?? AvailableVersions?.FirstOrDefault() + : AvailableVersions?.FirstOrDefault(); + + CanInstall = !ShowDuplicateWarning; + } + + private async Task UpdateCommits(string branchName) + { + CanInstall = false; + + var commits = await SelectedPackage.GetAllCommits(branchName); + if (commits != null) + { + AvailableCommits = new ObservableCollection(commits); + SelectedCommit = AvailableCommits.FirstOrDefault(); + } + + CanInstall = !ShowDuplicateWarning; + } + + partial void OnInstallNameChanged(string? value) + { + ShowDuplicateWarning = settingsManager.Settings.InstalledPackages.Any( + p => p.LibraryPath == $"Packages{Path.DirectorySeparatorChar}{value}" + ); + CanInstall = !ShowDuplicateWarning; + } + + 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 3e834be6a..6066fd027 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs @@ -9,10 +9,12 @@ 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; @@ -28,7 +30,7 @@ using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; @@ -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 PackageManagerViewModel( 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 override async Task OnLoadedAsync() 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 override void OnUnloaded() 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() @@ -195,7 +162,7 @@ private async Task CheckPackagesForUpdates() private IEnumerable IndexUnknownPackages() { - var packageDir = new DirectoryPath(settingsManager.LibraryDir).JoinDir("Packages"); + var packageDir = settingsManager.LibraryDir.JoinDir("Packages"); if (!packageDir.Exists) { @@ -204,11 +171,7 @@ private IEnumerable IndexUnknownPackages() 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 @@ var subDir in packageDir.Info } } + [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/Progress/PackageInstallProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Progress/PackageInstallProgressItemViewModel.cs index 9cb0eb3d9..cd4f5f46d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Progress/PackageInstallProgressItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Progress/PackageInstallProgressItemViewModel.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls; using Avalonia.Threading; using FluentAvalonia.UI.Controls; using StabilityMatrix.Avalonia.Controls; @@ -17,24 +20,22 @@ public class PackageInstallProgressItemViewModel : ProgressItemViewModelBase private readonly IPackageModificationRunner packageModificationRunner; private BetterContentDialog? dialog; - public PackageInstallProgressItemViewModel( - IPackageModificationRunner packageModificationRunner, - bool hideCloseButton = false - ) + public PackageInstallProgressItemViewModel(IPackageModificationRunner packageModificationRunner) { this.packageModificationRunner = packageModificationRunner; + Id = packageModificationRunner.Id; Name = packageModificationRunner.CurrentStep?.ProgressTitle; Progress.Value = packageModificationRunner.CurrentProgress.Percentage; Progress.Text = packageModificationRunner.ConsoleOutput.LastOrDefault(); Progress.IsIndeterminate = packageModificationRunner.CurrentProgress.IsIndeterminate; - Progress.HideCloseButton = hideCloseButton; + Progress.HideCloseButton = packageModificationRunner.HideCloseButton; - Progress.Console.StartUpdates(); + if (Design.IsDesignMode) + return; - Progress.Console.Post( - string.Join(Environment.NewLine, packageModificationRunner.ConsoleOutput) - ); + Progress.Console.StartUpdates(); + Progress.Console.Post(string.Join(Environment.NewLine, packageModificationRunner.ConsoleOutput)); packageModificationRunner.ProgressChanged += PackageModificationRunnerOnProgressChanged; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs index c06336971..4e9f36caa 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using AsyncAwaitBestPractices; using Avalonia.Collections; @@ -21,9 +24,11 @@ using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Services; +using Notification = DesktopNotifications.Notification; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Progress; @@ -57,38 +62,51 @@ INavigationService settingsNavService // Attach to the event trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded; - EventManager.Instance.PackageInstallProgressAdded += InstanceOnPackageInstallProgressAdded; EventManager.Instance.ToggleProgressFlyout += (_, _) => IsOpen = !IsOpen; + EventManager.Instance.PackageInstallProgressAdded += InstanceOnPackageInstallProgressAdded; + EventManager.Instance.RecommendedModelsDialogClosed += InstanceOnRecommendedModelsDialogClosed; } - private void InstanceOnPackageInstallProgressAdded( - object? sender, - IPackageModificationRunner runner - ) + private void InstanceOnRecommendedModelsDialogClosed(object? sender, EventArgs e) + { + var vm = ProgressItems.OfType().FirstOrDefault(); + vm?.ShowProgressDialog().SafeFireAndForget(); + } + + private void InstanceOnPackageInstallProgressAdded(object? sender, IPackageModificationRunner runner) { AddPackageInstall(runner).SafeFireAndForget(); } private void TrackedDownloadService_OnDownloadAdded(object? sender, TrackedDownload e) { - var vm = new DownloadProgressItemViewModel(e); - // Attach notification handlers + // Use Changing because Changed might be called after the download is removed e.ProgressStateChanged += (s, state) => { + Debug.WriteLine($"Download {e.FileName} state changed to {state}"); var download = s as TrackedDownload; switch (state) { case ProgressState.Success: - Dispatcher.UIThread.Post(() => - { - notificationService.Show( - "Download Completed", - $"Download of {e.FileName} completed successfully.", - NotificationType.Success - ); - }); + var imageFile = e.DownloadDirectory.EnumerateFiles( + $"{Path.GetFileNameWithoutExtension(e.FileName)}.preview.*" + ) + .FirstOrDefault(); + + notificationService + .ShowAsync( + NotificationKey.Download_Completed, + new Notification + { + Title = "Download Completed", + Body = $"Download of {e.FileName} completed successfully.", + BodyImagePath = imageFile?.FullPath + } + ) + .SafeFireAndForget(); + break; case ProgressState.Failed: var msg = ""; @@ -130,28 +148,34 @@ exception is UnauthorizedAccessException $"({exception.GetType().Name}) {exception.InnerException?.Message ?? exception.Message}"; } - Dispatcher.UIThread.Post(() => - { - notificationService.ShowPersistent( - "Download Failed", - $"Download of {e.FileName} failed: {msg}", - NotificationType.Error - ); - }); + notificationService + .ShowAsync( + NotificationKey.Download_Failed, + new Notification + { + Title = "Download Failed", + Body = $"Download of {e.FileName} failed: {msg}" + } + ) + .SafeFireAndForget(); break; case ProgressState.Cancelled: - Dispatcher.UIThread.Post(() => - { - notificationService.Show( - "Download Cancelled", - $"Download of {e.FileName} was cancelled.", - NotificationType.Warning - ); - }); + notificationService + .ShowAsync( + NotificationKey.Download_Canceled, + new Notification + { + Title = "Download Cancelled", + Body = $"Download of {e.FileName} was cancelled." + } + ) + .SafeFireAndForget(); break; } }; + var vm = new DownloadProgressItemViewModel(e); + ProgressItems.Add(vm); } @@ -175,15 +199,10 @@ private Task AddPackageInstall(IPackageModificationRunner packageModificationRun return Task.CompletedTask; } - var vm = new PackageInstallProgressItemViewModel( - packageModificationRunner, - packageModificationRunner.HideCloseButton - ); + var vm = new PackageInstallProgressItemViewModel(packageModificationRunner); ProgressItems.Add(vm); - return packageModificationRunner.ShowDialogOnStart - ? vm.ShowProgressDialog() - : Task.CompletedTask; + return packageModificationRunner.ShowDialogOnStart ? vm.ShowProgressDialog() : Task.CompletedTask; } private void ShowFailedNotification(string title, string message) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs index 15cdf3720..def248c3f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs @@ -23,7 +23,7 @@ using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; using TeachingTip = StabilityMatrix.Core.Models.Settings.TeachingTip; namespace StabilityMatrix.Avalonia.ViewModels.Settings; @@ -42,8 +42,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase public override string Title => "Accounts"; /// - public override IconSource IconSource => - new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; + public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectLykosCommand))] @@ -59,12 +58,10 @@ public partial class AccountSettingsViewModel : PageViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))] - private LykosAccountStatusUpdateEventArgs lykosStatus = - LykosAccountStatusUpdateEventArgs.Disconnected; + private LykosAccountStatusUpdateEventArgs lykosStatus = LykosAccountStatusUpdateEventArgs.Disconnected; [ObservableProperty] - private CivitAccountStatusUpdateEventArgs civitStatus = - CivitAccountStatusUpdateEventArgs.Disconnected; + private CivitAccountStatusUpdateEventArgs civitStatus = CivitAccountStatusUpdateEventArgs.Disconnected; public AccountSettingsViewModel( IAccountsService accountsService, @@ -116,11 +113,7 @@ public override void OnLoaded() private async Task BeforeConnectCheck() { // Show credentials storage notice if not seen - if ( - !settingsManager.Settings.SeenTeachingTips.Contains( - TeachingTip.AccountsCredentialsStorageNotice - ) - ) + if (!settingsManager.Settings.SeenTeachingTips.Contains(TeachingTip.AccountsCredentialsStorageNotice)) { var dialog = new BetterContentDialog { @@ -242,10 +235,7 @@ Login to [CivitAI](https://civitai.com/) and head to your [Account](https://civi ); dialog.PrimaryButtonText = Resources.Action_Connect; - if ( - await dialog.ShowAsync() != ContentDialogResult.Primary - || textFields[0].Text is not { } apiToken - ) + if (await dialog.ShowAsync() != ContentDialogResult.Primary || textFields[0].Text is not { } apiToken) { return; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs index f88d17f71..290bed3c5 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs @@ -24,7 +24,7 @@ using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Settings; @@ -40,7 +40,8 @@ public partial class InferenceSettingsViewModel : PageViewModelBase public override string Title => "Inference"; /// - public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; [ObservableProperty] private bool isPromptCompletionEnabled = true; @@ -64,8 +65,9 @@ public partial class InferenceSettingsViewModel : PageViewModelBase public IEnumerable OutputImageFileNameFormatVars => FileNameFormatProvider .GetSample() - .Substitutions - .Select(kv => new FileNameFormatVar { Variable = $"{{{kv.Key}}}", Example = kv.Value.Invoke() }); + .Substitutions.Select( + kv => new FileNameFormatVar { Variable = $"{{{kv.Key}}}", Example = kv.Value.Invoke() } + ); [ObservableProperty] private bool isImageViewerPixelGridEnabled = true; @@ -114,7 +116,10 @@ ISettingsManager settingsManager var provider = FileNameFormatProvider.GetSample(); var template = formatProperty.Value ?? string.Empty; - if (!string.IsNullOrEmpty(template) && provider.Validate(template) == ValidationResult.Success) + if ( + !string.IsNullOrEmpty(template) + && provider.Validate(template) == ValidationResult.Success + ) { var format = FileNameFormat.Parse(template, provider); OutputImageFileNameFormatSample = format.GetFileName() + ".png"; @@ -147,7 +152,10 @@ ISettingsManager settingsManager /// /// Validator for /// - public static ValidationResult ValidateOutputImageFileNameFormat(string? format, ValidationContext context) + public static ValidationResult ValidateOutputImageFileNameFormat( + string? format, + ValidationContext context + ) { return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty); } @@ -169,7 +177,7 @@ private async Task ImportTagCsv() 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 2b79de91d..1e2dace5b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -24,7 +24,9 @@ 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,12 +48,14 @@ 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.Processes; using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Settings; @@ -74,8 +78,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 +139,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 +206,16 @@ IAccountsService accountsService 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 +255,12 @@ private void OnHardwareInfoUpdateTimerTick(object? sender, EventArgs e) { 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 +299,17 @@ partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newVal 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(); - } - }); + // Start the new app while passing our own PID to wait for exit + Process.Start(Compat.AppCurrentPath, $"--wait-for-exit-pid {Environment.ProcessId}"); + + // Shutdown the current app + App.Shutdown(); + } + }); } else { @@ -313,16 +336,14 @@ public async Task ResetCheckpointCache() [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 +371,7 @@ private async Task OpenEnvVarsDialog() { // 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 +425,10 @@ private async Task AddToStartMenu() 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 +767,129 @@ private async Task DebugTrackedDownload() } #endregion + #region Debug Commands + + public CommandItem[] DebugCommands => + [ + new CommandItem(DebugFindLocalModelFromIndexCommand), + new CommandItem(DebugExtractDmgCommand), + new CommandItem(DebugShowNativeNotificationCommand) + ]; + + [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); + } + + [RelayCommand] + private async Task DebugShowNativeNotification() + { + var nativeManager = await notificationService.GetNativeNotificationManagerAsync(); + + if (nativeManager is null) + { + notificationService.Show( + "Not supported", + "Native notifications are not supported on this platform.", + NotificationType.Warning + ); + return; + } + + await nativeManager.ShowNotification( + new DesktopNotifications.Notification + { + Title = "Test Notification", + Body = "Here is some message", + Buttons = { ("Action", "__Debug_Action"), ("Close", "__Debug_Close"), } + } + ); + } + + #endregion + #region Info Section public void OnVersionClick() diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/NotificationSettingsItem.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/NotificationSettingsItem.cs new file mode 100644 index 000000000..7dd037126 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/NotificationSettingsItem.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Settings; + +[Transient] +[ManagedService] +public partial class NotificationSettingsItem(ISettingsManager settingsManager) : ObservableObject +{ + public NotificationKey? Key { get; set; } + + [ObservableProperty] + private NotificationOption? option; + + public static IEnumerable AvailableOptions => Enum.GetValues(); + + partial void OnOptionChanged(NotificationOption? oldValue, NotificationOption? newValue) + { + if (Key is null || oldValue is null || newValue is null) + return; + + settingsManager.Transaction(settings => + { + settings.NotificationOptions[Key] = newValue.Value; + }); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/NotificationSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/NotificationSettingsViewModel.cs new file mode 100644 index 000000000..fc0c21a9a --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/NotificationSettingsViewModel.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views.Settings; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Settings; + +[View(typeof(NotificationSettingsPage))] +[Singleton] +[ManagedService] +public partial class NotificationSettingsViewModel(ISettingsManager settingsManager) : PageViewModelBase +{ + public override string Title => "Notifications"; + public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Alert }; + + [ObservableProperty] + private IReadOnlyList items = []; + + public override void OnLoaded() + { + base.OnLoaded(); + + Items = GetItems().OrderBy(item => item.Key?.Value).ToImmutableArray(); + } + + private IEnumerable GetItems() + { + var settingsOptions = settingsManager.Settings.NotificationOptions; + + foreach (var notificationKey in NotificationKey.All.Values) + { + // If in settings, include settings value, otherwise default + if (!settingsOptions.TryGetValue(notificationKey, out var option)) + { + option = notificationKey.DefaultOption; + } + + yield return new NotificationSettingsItem(settingsManager) + { + Key = notificationKey, + Option = option + }; + } + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs index ff7a263db..a0babde05 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs @@ -21,7 +21,7 @@ using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Updater; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Settings; @@ -111,9 +111,7 @@ INavigationService settingsNavService var isBetaChannelsEnabled = args.User?.IsActiveSupporter == true; foreach ( - var card in AvailableUpdateChannelCards.Where( - c => c.UpdateChannel > UpdateChannel.Stable - ) + var card in AvailableUpdateChannelCards.Where(c => c.UpdateChannel > UpdateChannel.Stable) ) { card.IsSelectable = isBetaChannelsEnabled; @@ -169,8 +167,8 @@ public bool VerifyChannelSelection(UpdateChannelCard card) public void ShowLoginRequiredDialog() { - Dispatcher.UIThread - .InvokeAsync(async () => + Dispatcher + .UIThread.InvokeAsync(async () => { var dialog = DialogHelper.CreateTaskDialog( "Become a Supporter", @@ -214,7 +212,8 @@ partial void OnUpdateStatusChanged(UpdateStatusChangedEventArgs? value) // Use maximum version from platforms equal or lower than current foreach (var card in AvailableUpdateChannelCards) { - card.LatestVersion = value?.UpdateChannels + card.LatestVersion = value + ?.UpdateChannels .Where(kv => kv.Key <= card.UpdateChannel) .Select(kv => kv.Value) .MaxBy(info => info.Version, SemVersion.PrecedenceComparer) diff --git a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs index 70f221be6..e79ef3c37 100644 --- a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs @@ -9,7 +9,7 @@ using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Core.Attributes; using Symbol = FluentIcons.Common.Symbol; -using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; @@ -18,7 +18,8 @@ namespace StabilityMatrix.Avalonia.ViewModels; public partial class SettingsViewModel : PageViewModelBase { 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 }; public IReadOnlyList SubPages { get; } @@ -35,7 +36,8 @@ public SettingsViewModel(ServiceManager vmFactory) vmFactory.Get(), vmFactory.Get(), vmFactory.Get(), - vmFactory.Get() + vmFactory.Get(), + vmFactory.Get() }; CurrentPagePath.AddRange(SubPages); diff --git a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml index a3afe9669..47624c72a 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml @@ -14,5 +14,6 @@ x:DataType="viewModels:CheckpointBrowserViewModel" mc:Ignorable="d" x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage"> - + diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml index a044be5d6..c2831986f 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml @@ -10,6 +10,9 @@ xmlns:checkpointManager="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:avalonia="https://github.com/projektanker/icons.avalonia" + xmlns:api="clr-namespace:StabilityMatrix.Core.Models.Api;assembly=StabilityMatrix.Core" + xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections" + xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" d:DataContext="{x:Static mocks:DesignData.CheckpointsPageViewModel}" x:CompileBindings="True" x:DataType="vm:CheckpointsPageViewModel" @@ -25,6 +28,8 @@ Opacity="0.2" x:Key="TextDropShadowEffect" /> + + - - + + + + True + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + TextWrapping="WrapWithOverflow" /> + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + FontWeight="Medium" + Text="{x:Static lang:Resources.Label_UpdateAvailable}" /> + + + - - + + + + + + + + + + + + + - - + + - + VerticalAlignment="Center" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/RecommendedModelsDialog.axaml.cs b/StabilityMatrix.Avalonia/Views/Dialogs/RecommendedModelsDialog.axaml.cs new file mode 100644 index 000000000..c478fa10c --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/RecommendedModelsDialog.axaml.cs @@ -0,0 +1,11 @@ +using StabilityMatrix.Avalonia.Controls; + +namespace StabilityMatrix.Avalonia.Views.Dialogs; + +public partial class RecommendedModelsDialog : UserControlBase +{ + public RecommendedModelsDialog() + { + InitializeComponent(); + } +} diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/SelectModelVersionDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/SelectModelVersionDialog.axaml index 632c8fd45..e53b82f0e 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/SelectModelVersionDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/SelectModelVersionDialog.axaml @@ -7,11 +7,13 @@ xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData" mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="850" x:DataType="dialogs:SelectModelVersionViewModel" - xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" + xmlns:fluentAvalonia="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:markupExtensions="clr-namespace:StabilityMatrix.Avalonia.MarkupExtensions" + xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" d:DataContext="{x:Static designData:DesignData.SelectModelVersionViewModel}" x:Class="StabilityMatrix.Avalonia.Views.Dialogs.SelectModelVersionDialog"> @@ -21,6 +23,11 @@ MinWidth="700" RowDefinitions="Auto, *, Auto" ColumnDefinitions="*,Auto,*"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs new file mode 100644 index 000000000..5abbe50b7 --- /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 000000000..91b2e671e --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml @@ -0,0 +1,179 @@ + + + + + + + + + + + + [PublicAPI] -public class StringJsonConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> - : JsonConverter +public class StringJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T +> : JsonConverter { /// public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -32,6 +33,28 @@ public class StringJsonConverter<[DynamicallyAccessedMembers(DynamicallyAccessed return (T?)Activator.CreateInstance(typeToConvert, value); } + /// + public override T ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException(); + } + + var value = reader.GetString(); + if (value is null) + { + throw new JsonException("Property name cannot be null"); + } + + return (T?)Activator.CreateInstance(typeToConvert, value) + ?? throw new JsonException("Property name cannot be null"); + } + /// public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) { @@ -50,4 +73,22 @@ public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOption writer.WriteStringValue(value.ToString()); } } + + /// + public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + if (value is null) + { + throw new JsonException("Property name cannot be null"); + } + + if (value is IFormattable formattable) + { + writer.WriteStringValue(formattable.ToString(null, CultureInfo.InvariantCulture)); + } + else + { + writer.WriteStringValue(value.ToString()); + } + } } diff --git a/StabilityMatrix.Avalonia/Extensions/DirectoryPathExtensions.cs b/StabilityMatrix.Core/Extensions/DirectoryPathExtensions.cs similarity index 59% rename from StabilityMatrix.Avalonia/Extensions/DirectoryPathExtensions.cs rename to StabilityMatrix.Core/Extensions/DirectoryPathExtensions.cs index 508d0818a..eb6251d0b 100644 --- a/StabilityMatrix.Avalonia/Extensions/DirectoryPathExtensions.cs +++ b/StabilityMatrix.Core/Extensions/DirectoryPathExtensions.cs @@ -1,12 +1,9 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using Polly; using StabilityMatrix.Core.Models.FileInterfaces; -namespace StabilityMatrix.Avalonia.Extensions; +namespace StabilityMatrix.Core.Extensions; [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public static class DirectoryPathExtensions @@ -15,21 +12,32 @@ public static class DirectoryPathExtensions /// Deletes a directory and all of its contents recursively. /// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff. ///
- public static Task DeleteVerboseAsync(this DirectoryPath directory, ILogger? logger = default) + public static Task DeleteVerboseAsync( + this DirectoryPath directory, + ILogger? logger = default, + CancellationToken cancellationToken = default + ) { - var policy = Policy.Handle() - .WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)), + var policy = Policy + .Handle() + .WaitAndRetryAsync( + 3, + attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)), onRetry: (exception, calculatedWaitDuration) => { logger?.LogWarning( exception, "Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}", - directory, calculatedWaitDuration); - }); + directory, + calculatedWaitDuration + ); + } + ); return policy.ExecuteAsync(async () => { - await Task.Run(() => { DeleteVerbose(directory, logger); }); + await Task.Run(() => DeleteVerbose(directory, logger, cancellationToken), cancellationToken) + .ConfigureAwait(false); }); } @@ -37,8 +45,14 @@ public static Task DeleteVerboseAsync(this DirectoryPath directory, ILogger? log /// Deletes a directory and all of its contents recursively. /// Removes link targets without deleting the source. ///
- public static void DeleteVerbose(this DirectoryPath directory, ILogger? logger = default) + public static void DeleteVerbose( + this DirectoryPath directory, + ILogger? logger = default, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + // Skip if directory does not exist if (!directory.Exists) { @@ -47,7 +61,7 @@ public static void DeleteVerbose(this DirectoryPath directory, ILogger? logger = // For junction points, delete with recursive false if (directory.IsSymbolicLink) { - logger?.LogInformation("Removing junction point {TargetDirectory}", directory); + logger?.LogInformation("Removing junction point {TargetDirectory}", directory.FullPath); try { directory.Delete(false); @@ -55,29 +69,31 @@ public static void DeleteVerbose(this DirectoryPath directory, ILogger? logger = } catch (IOException ex) { - throw new IOException($"Failed to delete junction point {directory}", ex); + throw new IOException($"Failed to delete junction point {directory.FullPath}", ex); } } // Recursively delete all subdirectories - foreach (var subDir in directory.Info.EnumerateDirectories()) + foreach (var subDir in directory.EnumerateDirectories()) { - DeleteVerbose(subDir, logger); + DeleteVerbose(subDir, logger, cancellationToken); } - + // Delete all files in the directory - foreach (var filePath in directory.Info.EnumerateFiles()) + foreach (var filePath in directory.EnumerateFiles()) { + cancellationToken.ThrowIfCancellationRequested(); + try { - filePath.Attributes = FileAttributes.Normal; + filePath.Info.Attributes = FileAttributes.Normal; filePath.Delete(); } catch (IOException ex) { - throw new IOException($"Failed to delete file {filePath.FullName}", ex); + throw new IOException($"Failed to delete file {filePath.FullPath}", ex); } } - + // Delete this directory try { diff --git a/StabilityMatrix.Core/Extensions/NullableExtensions.cs b/StabilityMatrix.Core/Extensions/NullableExtensions.cs new file mode 100644 index 000000000..64264ef69 --- /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 040422760..c61893bd6 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 async Task AddToArchive7Z(string archivePath, string sourceDirecto 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 @@ IProgress progress 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 async Task Extract( }; } - 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 async Task ExtractManaged(Stream stream, string outputDirectory) } 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 async Task ExtractManaged(Stream stream, string outputDirectory) } } } + + [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/Compat.cs b/StabilityMatrix.Core/Helper/Compat.cs index 9a014424a..9e4b352f0 100644 --- a/StabilityMatrix.Core/Helper/Compat.cs +++ b/StabilityMatrix.Core/Helper/Compat.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using Semver; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Core.Helper; @@ -62,10 +63,21 @@ public static void SetAppDataHome(string path) public static DirectoryPath AppCurrentDir { get; } /// - /// Current path to the app. + /// Current path to the app binary. /// public static FilePath AppCurrentPath => AppCurrentDir.JoinFile(GetExecutableName()); + /// + /// Path to the .app bundle on macOS. + /// + [SupportedOSPlatform("macos")] + public static DirectoryPath? AppBundleCurrentPath { get; } + + /// + /// Either the File or directory on macOS. + /// + public static FileSystemPath AppOrBundleCurrentPath => IsMacOS ? AppBundleCurrentPath! : AppCurrentPath; + // File extensions /// /// Platform-specific executable extension. @@ -103,7 +115,14 @@ static Compat() else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { Platform = PlatformKind.MacOS | PlatformKind.Unix; - AppCurrentDir = AppContext.BaseDirectory; // TODO: check this + + // This is ./.app/Contents/MacOS + var macDir = new DirectoryPath(AppContext.BaseDirectory); + // We need to go up two directories to get the .app directory + AppBundleCurrentPath = macDir.Parent?.Parent; + // Then CurrentDir is the next parent + AppCurrentDir = AppBundleCurrentPath!.Parent!; + ExeExtension = ""; DllExtension = ".dylib"; } @@ -112,11 +131,9 @@ static Compat() Platform = PlatformKind.Linux | PlatformKind.Unix; // For AppImage builds, the path is in `$APPIMAGE` - var appPath = - Environment.GetEnvironmentVariable("APPIMAGE") ?? AppContext.BaseDirectory; + var appPath = Environment.GetEnvironmentVariable("APPIMAGE") ?? AppContext.BaseDirectory; AppCurrentDir = - Path.GetDirectoryName(appPath) - ?? throw new Exception("Could not find application directory"); + Path.GetDirectoryName(appPath) ?? throw new Exception("Could not find application directory"); ExeExtension = ""; DllExtension = ".so"; } @@ -186,12 +203,24 @@ public static string GetExecutableName() return Path.GetFileName(fullPath); } + /// + /// Get the current application executable or bundle name. + /// + public static string GetAppName() + { + // For other platforms, this is the same as the executable name + if (!IsMacOS) + { + return GetExecutableName(); + } + + // On macOS, get name of current bundle + return Path.GetFileName(AppBundleCurrentPath.Unwrap()); + } + public static string GetEnvPathWithExtensions(params string[] paths) { - var currentPath = Environment.GetEnvironmentVariable( - "PATH", - EnvironmentVariableTarget.Process - ); + var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); var newPath = string.Join(PathDelimiter, paths); if (string.IsNullOrEmpty(currentPath)) diff --git a/StabilityMatrix.Core/Helper/EventManager.cs b/StabilityMatrix.Core/Helper/EventManager.cs index 72a2b3c37..e763022a8 100644 --- a/StabilityMatrix.Core/Helper/EventManager.cs +++ b/StabilityMatrix.Core/Helper/EventManager.cs @@ -26,26 +26,30 @@ private EventManager() { } public event EventHandler? ScrollToBottomRequested; public event EventHandler? ProgressChanged; public event EventHandler? RunningPackageStatusChanged; - public event EventHandler? PackageInstallProgressAdded; + public delegate Task AddPackageInstallEventHandler( + object? sender, + IPackageModificationRunner runner, + IReadOnlyList steps, + Action onCompleted + ); public event EventHandler? ToggleProgressFlyout; - public event EventHandler? CultureChanged; - public event EventHandler? ModelIndexChanged; - public event EventHandler? ImageFileAdded; public event EventHandler? InferenceTextToImageRequested; public event EventHandler? InferenceUpscaleRequested; + public event EventHandler? InferenceImageToImageRequested; + public event EventHandler? InferenceImageToVideoRequested; + public event EventHandler? NavigateAndFindCivitModelRequested; + public event EventHandler? DownloadsTeachingTipRequested; + public event EventHandler? RecommendedModelsDialogClosed; - 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 +57,9 @@ public void OnOneClickInstallFinished(bool skipped) => 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 +85,19 @@ public void OnInferenceTextToImageRequested(LocalImageFile imageFile) => 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); + + public void OnNavigateAndFindCivitModelRequested(int modelId) => + NavigateAndFindCivitModelRequested?.Invoke(this, modelId); + + public void OnDownloadsTeachingTipRequested() => + DownloadsTeachingTipRequested?.Invoke(this, EventArgs.Empty); + + public void OnRecommendedModelsDialogClosed() => + RecommendedModelsDialogClosed?.Invoke(this, EventArgs.Empty); } diff --git a/StabilityMatrix.Core/Helper/Factory/IPackageFactory.cs b/StabilityMatrix.Core/Helper/Factory/IPackageFactory.cs index 266ca3c3e..b088c3bfc 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 74f049b42..a31bca2b9 100644 --- a/StabilityMatrix.Core/Helper/Factory/PackageFactory.cs +++ b/StabilityMatrix.Core/Helper/Factory/PackageFactory.cs @@ -39,4 +39,7 @@ public IEnumerable GetAllAvailablePackages() ? 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 f356d59af..eba00cca8 100644 --- a/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs +++ b/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs @@ -6,6 +6,7 @@ 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 @@ private static IEnumerable IterGpuInfoLinux() } } + [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 IEnumerable IterGpuInfo() { 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 IEnumerable IterGpuInfo() 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 bool HasAmdGpu() 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 @@ private static MemoryInfo GetMemoryInfoImplWindows() 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 @@ private static CpuInfo GetCpuInfoImplWindows() { 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 @@ private static Task GetCpuInfoImplGenericAsync() { 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/IPrerequisiteHelper.cs b/StabilityMatrix.Core/Helper/IPrerequisiteHelper.cs index 1427a5468..d13c45c5f 100644 --- a/StabilityMatrix.Core/Helper/IPrerequisiteHelper.cs +++ b/StabilityMatrix.Core/Helper/IPrerequisiteHelper.cs @@ -1,4 +1,6 @@ using System.Runtime.Versioning; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -21,23 +23,148 @@ public interface IPrerequisiteHelper /// /// Run embedded git with the given arguments. /// - Task RunGit( - ProcessArgs args, - Action? onProcessOutput, - string? workingDirectory = null - ); + Task RunGit(ProcessArgs args, Action? onProcessOutput, string? workingDirectory = null); /// /// Run embedded git with the given arguments. /// Task RunGit(ProcessArgs args, string? workingDirectory = null); - Task GetGitOutput(string? workingDirectory = null, params string[] args); + Task GetGitOutput(ProcessArgs args, string? workingDirectory = null); + + async Task CheckIsGitRepository(string repositoryPath) + { + var result = await GetGitOutput(["rev-parse", "--is-inside-work-tree"], repositoryPath) + .ConfigureAwait(false); + + return result.ExitCode == 0 && result.StandardOutput?.Trim().ToLowerInvariant() == "true"; + } + + async Task GetGitRepositoryVersion(string repositoryPath) + { + var version = new GitVersion(); + + // Get tag + if ( + await GetGitOutput(["describe", "--tags", "--abbrev=0"], repositoryPath).ConfigureAwait(false) is + { IsSuccessExitCode: true } tagResult + ) + { + version = version with { Tag = tagResult.StandardOutput?.Trim() }; + } + + // Get branch + if ( + await GetGitOutput(["rev-parse", "--abbrev-ref", "HEAD"], repositoryPath).ConfigureAwait(false) is + { IsSuccessExitCode: true } branchResult + ) + { + version = version with { Branch = branchResult.StandardOutput?.Trim() }; + } + + // Get commit sha + if ( + await GetGitOutput(["rev-parse", "HEAD"], repositoryPath).ConfigureAwait(false) is + { IsSuccessExitCode: true } shaResult + ) + { + version = version with { CommitSha = shaResult.StandardOutput?.Trim() }; + } + + return version; + } + + async Task CloneGitRepository(string rootDir, string repositoryUrl, GitVersion? version = null) + { + // Latest if no version is given + if (version is null) + { + await RunGit(["clone", "--depth", "1", repositoryUrl], rootDir).ConfigureAwait(false); + } + else if (version.Tag is not null) + { + await RunGit(["clone", "--depth", "1", version.Tag, repositoryUrl], rootDir) + .ConfigureAwait(false); + } + else if (version.Branch is not null && version.CommitSha is not null) + { + await RunGit(["clone", "--depth", "1", "--branch", version.Branch, repositoryUrl], rootDir) + .ConfigureAwait(false); + + await RunGit(["checkout", version.CommitSha, "--force"], rootDir).ConfigureAwait(false); + } + else + { + throw new ArgumentException("Version must have a tag or branch and commit sha.", nameof(version)); + } + } + + async Task UpdateGitRepository(string repositoryDir, string repositoryUrl, GitVersion version) + { + // Specify Tag + if (version.Tag is not null) + { + await RunGit(["init"], repositoryDir).ConfigureAwait(false); + await RunGit(["remote", "add", "origin", repositoryUrl], repositoryDir).ConfigureAwait(false); + await RunGit(["fetch", "--tags"], repositoryDir).ConfigureAwait(false); + + await RunGit(["checkout", version.Tag, "--force"], repositoryDir).ConfigureAwait(false); + // Update submodules + await RunGit(["submodule", "update", "--init", "--recursive"], repositoryDir) + .ConfigureAwait(false); + } + // Specify Branch + CommitSha + else if (version.Branch is not null && version.CommitSha is not null) + { + await RunGit(["init"], repositoryDir).ConfigureAwait(false); + await RunGit(["remote", "add", "origin", repositoryUrl], repositoryDir).ConfigureAwait(false); + await RunGit(["fetch", "--tags"], repositoryDir).ConfigureAwait(false); + + await RunGit(["checkout", version.CommitSha, "--force"], repositoryDir).ConfigureAwait(false); + // Update submodules + await RunGit(["submodule", "update", "--init", "--recursive"], repositoryDir) + .ConfigureAwait(false); + } + // Specify Branch (Use latest commit) + else if (version.Branch is not null) + { + // Fetch + await RunGit(["fetch", "--tags", "--force"], repositoryDir).ConfigureAwait(false); + // Checkout + await RunGit(["checkout", version.Branch, "--force"], repositoryDir).ConfigureAwait(false); + // Pull latest + await RunGit(["pull", "--autostash", "origin", version.Branch], repositoryDir) + .ConfigureAwait(false); + // Update submodules + await RunGit(["submodule", "update", "--init", "--recursive"], repositoryDir) + .ConfigureAwait(false); + } + // Not specified + else + { + throw new ArgumentException( + "Version must have a tag, branch + commit sha, or branch only.", + nameof(version) + ); + } + } + + Task GetGitRepositoryRemoteOriginUrl(string repositoryPath) + { + return GetGitOutput(["config", "--get", "remote.origin.url"], repositoryPath); + } + Task InstallTkinterIfNecessary(IProgress? progress = null); Task RunNpm( ProcessArgs args, string? workingDirectory = null, - Action? onProcessOutput = null + Action? onProcessOutput = null, + IReadOnlyDictionary? envVars = null ); Task InstallNodeIfNecessary(IProgress? progress = null); + Task InstallPackageRequirements(BasePackage package, IProgress? progress = null); + Task InstallPackageRequirements( + List prerequisites, + IProgress? progress = null + ); } diff --git a/StabilityMatrix.Core/Helper/ImageMetadata.cs b/StabilityMatrix.Core/Helper/ImageMetadata.cs index 439242bff..2d3b9f392 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 static ( 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 static string ReadTextChunk(BinaryReader byteStream, string key) 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/ModelFinder.cs b/StabilityMatrix.Core/Helper/ModelFinder.cs index f16408215..bdce054d0 100644 --- a/StabilityMatrix.Core/Helper/ModelFinder.cs +++ b/StabilityMatrix.Core/Helper/ModelFinder.cs @@ -56,9 +56,13 @@ public ModelFinder(ILiteDbContext liteDbContext, ICivitApi civitApi) // VersionResponse is not actually the full data of ModelVersion, so find it again var version = model.ModelVersions!.First(version => version.Id == versionResponse.Id); - var file = versionResponse - .Files - .First(file => hashBlake3.Equals(file.Hashes.BLAKE3, StringComparison.OrdinalIgnoreCase)); + var file = versionResponse.Files.FirstOrDefault( + file => hashBlake3.Equals(file.Hashes.BLAKE3, StringComparison.OrdinalIgnoreCase) + ); + + // Archived models do not have files + if (file == null) + return null; return new ModelSearchResult(model, version, file); } @@ -79,7 +83,12 @@ public ModelFinder(ILiteDbContext liteDbContext, ICivitApi civitApi) } else { - Logger.Warn(e, "Could not find remote model version using hash {Hash}: {Error}", hashBlake3, e.Message); + Logger.Warn( + e, + "Could not find remote model version using hash {Hash}: {Error}", + hashBlake3, + e.Message + ); } return null; @@ -95,4 +104,35 @@ public ModelFinder(ILiteDbContext liteDbContext, ICivitApi civitApi) return null; } } + + public async Task> FindRemoteModelsById(IEnumerable ids) + { + var results = new List(); + + // split ids into batches of 20 + var batches = ids.Select((id, index) => (id, index)) + .GroupBy(tuple => tuple.index / 20) + .Select(group => group.Select(tuple => tuple.id)); + + foreach (var batch in batches) + { + try + { + var response = await civitApi + .GetModels(new CivitModelsRequest { CommaSeparatedModelIds = string.Join(",", batch) }) + .ConfigureAwait(false); + + if (response.Items == null || response.Items.Count == 0) + continue; + + results.AddRange(response.Items); + } + catch (Exception e) + { + Logger.Error("Error while finding remote models by id: {Error}", e.Message); + } + } + + return results; + } } diff --git a/StabilityMatrix.Core/Helper/MyTiffFile.cs b/StabilityMatrix.Core/Helper/MyTiffFile.cs new file mode 100644 index 000000000..dd65110bf --- /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/PrerequisiteHelper.cs b/StabilityMatrix.Core/Helper/PrerequisiteHelper.cs deleted file mode 100644 index 26144e76a..000000000 --- a/StabilityMatrix.Core/Helper/PrerequisiteHelper.cs +++ /dev/null @@ -1,451 +0,0 @@ -using System.Diagnostics; -using System.Reflection; -using System.Runtime.Versioning; -using Microsoft.Extensions.Logging; -using Microsoft.Win32; -using Octokit; -using StabilityMatrix.Core.Exceptions; -using StabilityMatrix.Core.Models.Progress; -using StabilityMatrix.Core.Processes; -using StabilityMatrix.Core.Services; - -namespace StabilityMatrix.Core.Helper; - -[SupportedOSPlatform("windows")] -[Obsolete("Not used in Avalonia, use WindowsPrerequisiteHelper instead")] -public class PrerequisiteHelper : IPrerequisiteHelper -{ - private readonly ILogger logger; - private readonly IGitHubClient gitHubClient; - private readonly IDownloadService downloadService; - private readonly ISettingsManager settingsManager; - - private const string VcRedistDownloadUrl = "https://aka.ms/vs/16/release/vc_redist.x64.exe"; - private const string PythonDownloadUrl = - "https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip"; - private const string PythonDownloadHashBlake3 = - "24923775f2e07392063aaa0c78fbd4ae0a320e1fc9c6cfbab63803402279fe5a"; - - private string HomeDir => settingsManager.LibraryDir; - - private string VcRedistDownloadPath => Path.Combine(HomeDir, "vcredist.x64.exe"); - - private string AssetsDir => Path.Combine(HomeDir, "Assets"); - private string SevenZipPath => Path.Combine(AssetsDir, "7za.exe"); - - private string PythonDownloadPath => Path.Combine(AssetsDir, "python-3.10.11-embed-amd64.zip"); - private string PythonDir => Path.Combine(AssetsDir, "Python310"); - private string PythonDllPath => Path.Combine(PythonDir, "python310.dll"); - private string PythonLibraryZipPath => Path.Combine(PythonDir, "python310.zip"); - private string GetPipPath => Path.Combine(PythonDir, "get-pip.pyc"); - - // Temporary directory to extract venv to during python install - private string VenvTempDir => Path.Combine(PythonDir, "venv"); - - private string PortableGitInstallDir => Path.Combine(HomeDir, "PortableGit"); - private string PortableGitDownloadPath => Path.Combine(HomeDir, "PortableGit.7z.exe"); - private string GitExePath => Path.Combine(PortableGitInstallDir, "bin", "git.exe"); - public string GitBinPath => Path.Combine(PortableGitInstallDir, "bin"); - - public bool IsPythonInstalled => File.Exists(PythonDllPath); - - public PrerequisiteHelper( - ILogger logger, - IGitHubClient gitHubClient, - IDownloadService downloadService, - ISettingsManager settingsManager - ) - { - this.logger = logger; - this.gitHubClient = gitHubClient; - this.downloadService = downloadService; - this.settingsManager = settingsManager; - } - - public async Task RunGit( - ProcessArgs args, - Action? onProcessOutput, - string? workingDirectory = null - ) - { - var process = ProcessRunner.StartAnsiProcess( - GitExePath, - args.ToArray(), - workingDirectory, - onProcessOutput - ); - await process.WaitForExitAsync().ConfigureAwait(false); - if (process.ExitCode != 0) - { - throw new ProcessException($"Git exited with code {process.ExitCode}"); - } - } - - public async Task RunGit(ProcessArgs args, string? workingDirectory = null) - { - var result = await ProcessRunner - .GetProcessResultAsync(GitExePath, args, workingDirectory) - .ConfigureAwait(false); - - result.EnsureSuccessExitCode(); - } - - public async Task GetGitOutput(string? workingDirectory = null, params string[] args) - { - var output = await ProcessRunner - .GetProcessOutputAsync( - GitExePath, - string.Join(" ", args), - workingDirectory: workingDirectory - ) - .ConfigureAwait(false); - return output; - } - - public Task InstallTkinterIfNecessary(IProgress? progress = null) - { - throw new NotImplementedException(); - } - - public Task RunNpm( - ProcessArgs args, - string? workingDirectory = null, - Action? onProcessOutput = null - ) - { - throw new NotImplementedException(); - } - - public Task InstallNodeIfNecessary(IProgress? progress = null) - { - throw new NotImplementedException(); - } - - public async Task InstallAllIfNecessary(IProgress? progress = null) - { - await InstallVcRedistIfNecessary(progress); - await UnpackResourcesIfNecessary(progress); - await InstallPythonIfNecessary(progress); - await InstallGitIfNecessary(progress); - } - - private static IEnumerable GetEmbeddedResources() - { - return Assembly.GetExecutingAssembly().GetManifestResourceNames(); - } - - private async Task ExtractEmbeddedResource(string resourceName, string outputDirectory) - { - // Convert resource name to file name - // from "StabilityMatrix.Assets.Python310.libssl-1_1.dll" - // to "Python310\libssl-1_1.dll" - var fileExt = Path.GetExtension(resourceName); - var fileName = - resourceName - .Replace(fileExt, "") - .Replace("StabilityMatrix.Assets.", "") - .Replace(".", Path.DirectorySeparatorChar.ToString()) + fileExt; - await using var resourceStream = Assembly - .GetExecutingAssembly() - .GetManifestResourceStream(resourceName)!; - if (resourceStream == null) - { - throw new Exception($"Resource {resourceName} not found"); - } - await using var fileStream = File.Create(Path.Combine(outputDirectory, fileName)); - await resourceStream.CopyToAsync(fileStream); - } - - /// - /// Extracts all embedded resources starting with resourceDir to outputDirectory - /// - private async Task ExtractAllEmbeddedResources( - string resourceDir, - string outputDirectory, - string resourceRoot = "StabilityMatrix.Assets." - ) - { - Directory.CreateDirectory(outputDirectory); - // Unpack from embedded resources - var resources = GetEmbeddedResources().Where(r => r.StartsWith(resourceDir)).ToArray(); - var total = resources.Length; - logger.LogInformation("Unpacking {Num} embedded resources...", total); - - // Unpack all resources - var copied = new List(); - foreach (var resourceName in resources) - { - // Convert resource name to file name - // from "StabilityMatrix.Assets.Python310.libssl-1_1.dll" - // to "Python310\libssl-1_1.dll" - var fileExt = Path.GetExtension(resourceName); - var fileName = - resourceName - .Replace(fileExt, "") - .Replace(resourceRoot, "") - .Replace(".", Path.DirectorySeparatorChar.ToString()) + fileExt; - // Unpack resource - await using var resourceStream = Assembly - .GetExecutingAssembly() - .GetManifestResourceStream(resourceName)!; - var outputFilePath = Path.Combine(outputDirectory, fileName); - // Create missing directories - var outputDir = Path.GetDirectoryName(outputFilePath); - if (outputDir != null) - { - Directory.CreateDirectory(outputDir); - } - await using var fileStream = File.Create(outputFilePath); - await resourceStream.CopyToAsync(fileStream); - copied.Add(outputFilePath); - } - logger.LogInformation( - "Successfully unpacked {Num} embedded resources: [{Resources}]", - total, - string.Join(",", copied) - ); - } - - public async Task UnpackResourcesIfNecessary(IProgress? progress = null) - { - // Skip if all files exist - if ( - File.Exists(SevenZipPath) - && File.Exists(PythonDllPath) - && File.Exists(PythonLibraryZipPath) - ) - { - return; - } - // Start Progress - progress?.Report(new ProgressReport(-1, "Unpacking resources...", isIndeterminate: true)); - // Create directories - Directory.CreateDirectory(AssetsDir); - Directory.CreateDirectory(PythonDir); - - // Run if 7za missing - if (!File.Exists(SevenZipPath)) - { - await ExtractEmbeddedResource("StabilityMatrix.Assets.7za.exe", AssetsDir); - await ExtractEmbeddedResource("StabilityMatrix.Assets.7za - LICENSE.txt", AssetsDir); - } - - progress?.Report(new ProgressReport(1f, "Unpacking complete")); - } - - public async Task InstallPythonIfNecessary(IProgress? progress = null) - { - if (File.Exists(PythonDllPath)) - { - logger.LogDebug("Python already installed at {PythonDllPath}", PythonDllPath); - return; - } - - logger.LogInformation("Python not found at {PythonDllPath}, downloading...", PythonDllPath); - - Directory.CreateDirectory(AssetsDir); - - // Delete existing python zip if it exists - if (File.Exists(PythonLibraryZipPath)) - { - File.Delete(PythonLibraryZipPath); - } - - logger.LogInformation( - "Downloading Python from {PythonLibraryZipUrl} to {PythonLibraryZipPath}", - PythonDownloadUrl, - PythonLibraryZipPath - ); - - // Cleanup to remove zip if download fails - try - { - // Download python zip - await downloadService.DownloadToFileAsync( - PythonDownloadUrl, - PythonDownloadPath, - progress: progress - ); - - // Verify python hash - var downloadHash = await FileHash.GetBlake3Async(PythonDownloadPath, progress); - if (downloadHash != PythonDownloadHashBlake3) - { - var fileExists = File.Exists(PythonDownloadPath); - var fileSize = new FileInfo(PythonDownloadPath).Length; - var msg = - $"Python download hash mismatch: {downloadHash} != {PythonDownloadHashBlake3} " - + $"(file exists: {fileExists}, size: {fileSize})"; - throw new Exception(msg); - } - - progress?.Report(new ProgressReport(progress: 1f, message: "Python download complete")); - - progress?.Report(new ProgressReport(-1, "Installing Python...", isIndeterminate: true)); - - // We also need 7z if it's not already unpacked - if (!File.Exists(SevenZipPath)) - { - await ExtractEmbeddedResource("StabilityMatrix.Assets.7za.exe", AssetsDir); - await ExtractEmbeddedResource( - "StabilityMatrix.Assets.7za - LICENSE.txt", - AssetsDir - ); - } - - // Delete existing python dir - if (Directory.Exists(PythonDir)) - { - Directory.Delete(PythonDir, true); - } - // Unzip python - - await ArchiveHelper.Extract7Z(PythonDownloadPath, PythonDir); - - try - { - // Extract embedded venv - await ExtractAllEmbeddedResources("StabilityMatrix.Assets.venv", PythonDir); - // Add venv to python's library zip - - await ArchiveHelper.AddToArchive7Z(PythonLibraryZipPath, VenvTempDir); - } - finally - { - // Remove venv - if (Directory.Exists(VenvTempDir)) - { - Directory.Delete(VenvTempDir, true); - } - } - - // Extract get-pip.pyc - await ExtractEmbeddedResource("StabilityMatrix.Assets.get-pip.pyc", PythonDir); - - // We need to uncomment the #import site line in python310._pth for pip to work - var pythonPthPath = Path.Combine(PythonDir, "python310._pth"); - var pythonPthContent = await File.ReadAllTextAsync(pythonPthPath); - pythonPthContent = pythonPthContent.Replace("#import site", "import site"); - await File.WriteAllTextAsync(pythonPthPath, pythonPthContent); - - progress?.Report(new ProgressReport(1f, "Python install complete")); - } - finally - { - // Always delete zip after download - if (File.Exists(PythonDownloadPath)) - { - File.Delete(PythonDownloadPath); - } - } - } - - public async Task InstallGitIfNecessary(IProgress? progress = null) - { - if (File.Exists(GitExePath)) - { - logger.LogDebug("Git already installed at {GitExePath}", GitExePath); - return; - } - - logger.LogInformation("Git not found at {GitExePath}, downloading...", GitExePath); - - var portableGitUrl = - "https://github.com/git-for-windows/git/releases/download/v2.41.0.windows.1/PortableGit-2.41.0-64-bit.7z.exe"; - - if (!File.Exists(PortableGitDownloadPath)) - { - await downloadService.DownloadToFileAsync( - portableGitUrl, - PortableGitDownloadPath, - progress: progress - ); - progress?.Report(new ProgressReport(progress: 1f, message: "Git download complete")); - } - - await UnzipGit(progress); - } - - [SupportedOSPlatform("windows")] - public async Task InstallVcRedistIfNecessary(IProgress? progress = null) - { - var registry = Registry.LocalMachine; - var key = registry.OpenSubKey( - @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64", - false - ); - if (key != null) - { - var buildId = Convert.ToUInt32(key.GetValue("Bld")); - if (buildId >= 30139) - { - return; - } - } - - logger.LogInformation("Downloading VC Redist"); - - await downloadService.DownloadToFileAsync( - VcRedistDownloadUrl, - VcRedistDownloadPath, - progress: progress - ); - progress?.Report( - new ProgressReport( - progress: 1f, - message: "Visual C++ download complete", - type: ProgressType.Download - ) - ); - - logger.LogInformation("Installing VC Redist"); - progress?.Report( - new ProgressReport( - progress: 0.5f, - isIndeterminate: true, - type: ProgressType.Generic, - message: "Installing prerequisites..." - ) - ); - var process = ProcessRunner.StartAnsiProcess( - VcRedistDownloadPath, - "/install /quiet /norestart" - ); - await process.WaitForExitAsync(); - progress?.Report( - new ProgressReport( - progress: 1f, - message: "Visual C++ install complete", - type: ProgressType.Generic - ) - ); - - File.Delete(VcRedistDownloadPath); - } - - public void UpdatePathExtensions() - { - settingsManager.Settings.PathExtensions?.Clear(); - settingsManager.AddPathExtension(GitBinPath); - settingsManager.InsertPathExtensions(); - } - - private async Task UnzipGit(IProgress? progress = null) - { - if (progress == null) - { - await ArchiveHelper.Extract7Z(PortableGitDownloadPath, PortableGitInstallDir); - } - else - { - await ArchiveHelper.Extract7Z(PortableGitDownloadPath, PortableGitInstallDir, progress); - } - - logger.LogInformation("Extracted Git"); - - File.Delete(PortableGitDownloadPath); - // Also add git to the path - settingsManager.AddPathExtension(GitBinPath); - settingsManager.InsertPathExtensions(); - } -} diff --git a/StabilityMatrix.Core/Helper/SharedFolders.cs b/StabilityMatrix.Core/Helper/SharedFolders.cs index 33fbc9141..7d4d5577a 100644 --- a/StabilityMatrix.Core/Helper/SharedFolders.cs +++ b/StabilityMatrix.Core/Helper/SharedFolders.cs @@ -35,9 +35,7 @@ private static void CreateLinkOrJunction(string junctionDir, string targetDir, b 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 void RemoveLinksForAllPackages() 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 07afca73f..5e8bfc2e7 100644 --- a/StabilityMatrix.Core/Helper/Utilities.cs +++ b/StabilityMatrix.Core/Helper/Utilities.cs @@ -13,8 +13,12 @@ public static string GetAppVersion() : $"{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 void CopyDirectory(string sourceDir, string destinationDir, bool r 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 void CopyDirectory(string sourceDir, string destinationDir, bool r 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/Helper/Webp/WebpReader.cs b/StabilityMatrix.Core/Helper/Webp/WebpReader.cs new file mode 100644 index 000000000..006fe1fc7 --- /dev/null +++ b/StabilityMatrix.Core/Helper/Webp/WebpReader.cs @@ -0,0 +1,57 @@ +using System.Text; + +namespace StabilityMatrix.Core.Helper.Webp; + +public class WebpReader(Stream stream) : BinaryReader(stream, Encoding.ASCII, true) +{ + private uint headerFileSize; + + public bool GetIsAnimatedFlag() + { + ReadHeader(); + + while (BaseStream.Position < headerFileSize) + { + if (ReadVoidChunk() is "ANMF" or "ANIM") + { + return true; + } + } + + return false; + } + + private void ReadHeader() + { + // RIFF + var riff = ReadBytes(4); + if (!riff.SequenceEqual([.."RIFF"u8])) + { + throw new InvalidDataException("Invalid RIFF header"); + } + + // Size: uint32 + headerFileSize = ReadUInt32(); + + // WEBP + var webp = ReadBytes(4); + if (!webp.SequenceEqual([.."WEBP"u8])) + { + throw new InvalidDataException("Invalid WEBP header"); + } + } + + // Read a single chunk and discard its contents + private string ReadVoidChunk() + { + // FourCC: 4 bytes in ASCII + var result = ReadBytes(4); + + // Size: uint32 + var size = ReadUInt32(); + + BaseStream.Seek(size, SeekOrigin.Current); + + return Encoding.ASCII.GetString(result); + } +} diff --git a/StabilityMatrix.Core/Models/Api/CivitBaseModelType.cs b/StabilityMatrix.Core/Models/Api/CivitBaseModelType.cs new file mode 100644 index 000000000..dd886f77d --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitBaseModelType.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Core.Models.Api; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CivitBaseModelType +{ + All, + + [StringValue("SD 1.5")] + Sd15, + + [StringValue("SD 1.5 LCM")] + Sd15Lcm, + + [StringValue("SD 2.1")] + Sd21, + + [StringValue("SDXL 0.9")] + Sdxl09, + + [StringValue("SDXL 1.0")] + Sdxl10, + + [StringValue("SDXL 1.0 LCM")] + Sdxl10Lcm, + + [StringValue("SDXL Turbo")] + SdxlTurbo, + Other, +} diff --git a/StabilityMatrix.Core/Models/Api/CivitFile.cs b/StabilityMatrix.Core/Models/Api/CivitFile.cs index 114bc2495..fa2c9d226 100644 --- a/StabilityMatrix.Core/Models/Api/CivitFile.cs +++ b/StabilityMatrix.Core/Models/Api/CivitFile.cs @@ -6,16 +6,16 @@ public class CivitFile { [JsonPropertyName("sizeKB")] public double SizeKb { get; set; } - + [JsonPropertyName("pickleScanResult")] public string PickleScanResult { get; set; } - + [JsonPropertyName("virusScanResult")] public string VirusScanResult { get; set; } - + [JsonPropertyName("scannedAt")] public DateTime? ScannedAt { get; set; } - + [JsonPropertyName("metadata")] public CivitFileMetadata Metadata { get; set; } @@ -24,19 +24,23 @@ public class CivitFile [JsonPropertyName("downloadUrl")] public string DownloadUrl { get; set; } - + [JsonPropertyName("hashes")] public CivitFileHashes Hashes { get; set; } - + [JsonPropertyName("type")] public CivitFileType Type { get; set; } - + + [JsonPropertyName("primary")] + public bool IsPrimary { get; set; } + private FileSizeType? fullFilesSize; public FileSizeType FullFilesSize { get { - if (fullFilesSize != null) return fullFilesSize; + if (fullFilesSize != null) + return fullFilesSize; fullFilesSize = new FileSizeType(SizeKb); return fullFilesSize; } 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 000000000..02a3c2af7 --- /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 000000000..abb665966 --- /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 a574d5ec0..c540019e4 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 48fa3a79d..874397ef6 100644 --- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs +++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs @@ -122,6 +122,14 @@ public record EmptyLatentImage : ComfyTypedNodeBase 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 record CheckpointLoaderSimple 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 record ControlNetApplyAdvanced 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 NodeBuilderConnections 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 NodeBuilderConnections 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 051cf7b2f..745abb745 100644 --- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/NodeDictionary.cs +++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/NodeDictionary.cs @@ -25,9 +25,7 @@ public string GetUniqueName(string nameBase) // 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/Api/Lykos/GetRecommendedModelsResponse.cs b/StabilityMatrix.Core/Models/Api/Lykos/GetRecommendedModelsResponse.cs new file mode 100644 index 000000000..29aa4596e --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/Lykos/GetRecommendedModelsResponse.cs @@ -0,0 +1,14 @@ +namespace StabilityMatrix.Core.Models.Api.Lykos; + +public class GetRecommendedModelsResponse +{ + public required ModelLists Sd15 { get; set; } + public required ModelLists Sdxl { get; set; } + public required ModelLists Decoders { get; set; } +} + +public class ModelLists +{ + public IEnumerable? CivitAi { get; set; } + public IEnumerable? HuggingFace { get; set; } +} diff --git a/StabilityMatrix.Core/Models/Database/LocalImageFile.cs b/StabilityMatrix.Core/Models/Database/LocalImageFile.cs index 1abde8dd0..a6446d475 100644 --- a/StabilityMatrix.Core/Models/Database/LocalImageFile.cs +++ b/StabilityMatrix.Core/Models/Database/LocalImageFile.cs @@ -1,4 +1,6 @@ -using DynamicData.Tests; +using System.Text.Json; +using DynamicData.Tests; +using MetadataExtractor.Formats.Exif; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.FileInterfaces; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -48,19 +50,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 +82,39 @@ 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 + ); + + GenerationParameters? parameters = null; + try + { + parameters = string.IsNullOrWhiteSpace(paramsJson) + ? null + : JsonSerializer.Deserialize(paramsJson); + } + catch (JsonException) + { + // just don't load params I guess, no logger here <_< + } + + 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 +150,11 @@ public static LocalImageFile FromPath(FilePath filePath) } 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 e5f92cef3..96c3092cd 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,21 @@ public class LocalModelFile /// public string? PreviewImageRelativePath { get; set; } + /// + /// Optional preview image full path. Takes priority over . + /// + public string? PreviewImageFullPath { get; set; } + + /// + /// Whether or not an update is available for this model + /// + public bool HasUpdate { get; set; } + + /// + /// Last time this model was checked for an update + /// + public DateTimeOffset LastUpdateCheck { get; set; } + /// /// File name of the relative path. /// @@ -45,7 +60,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 +95,15 @@ public string GetFullPath(string rootModelDirectory) public string? GetPreviewImageFullPath(string rootModelDirectory) { - return PreviewImageRelativePath == null ? null : Path.Combine(rootModelDirectory, PreviewImageRelativePath); - } - - [BsonIgnore] - public string FullPathGlobal => GetFullPath(GlobalConfig.LibraryDir.JoinDir("Models")); + if (PreviewImageFullPath != null) + return PreviewImageFullPath; - [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 +124,7 @@ public override bool Equals(object? obj) public override int GetHashCode() { return RelativePath.GetHashCode(); - } + }*/ public static readonly HashSet SupportedCheckpointExtensions = [ @@ -94,6 +134,6 @@ public override int GetHashCode() ".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 bd94d5f86..1bd4d18ca 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs @@ -1,20 +1,22 @@ using System.Collections; -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using JetBrains.Annotations; using StabilityMatrix.Core.Converters.Json; namespace StabilityMatrix.Core.Models.FileInterfaces; -[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +[PublicAPI] [JsonConverter(typeof(StringJsonConverter))] public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable { private DirectoryInfo? info; - // ReSharper disable once MemberCanBePrivate.Global [JsonIgnore] public DirectoryInfo Info => info ??= new DirectoryInfo(FullPath); + [JsonIgnore] + FileSystemInfo IPathObject.Info => Info; + [JsonIgnore] public bool IsSymbolicLink { @@ -57,6 +59,11 @@ public DirectoryPath(DirectoryInfo info) public DirectoryPath(params string[] paths) : base(paths) { } + public DirectoryPath RelativeTo(DirectoryPath path) + { + return new DirectoryPath(Path.GetRelativePath(path.FullPath, FullPath)); + } + /// public long GetSize() { @@ -81,9 +88,7 @@ public long GetSize(bool includeSymbolicLinks) .Sum(file => 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; } @@ -122,6 +127,102 @@ public Task GetSizeAsync(bool includeSymbolicLinks) ///
public Task DeleteAsync(bool recursive) => Task.Run(() => Delete(recursive)); + void IPathObject.Delete() => Info.Delete(true); + + Task IPathObject.DeleteAsync() => DeleteAsync(true); + + private void ThrowIfNotExists() + { + if (!Exists) + { + throw new DirectoryNotFoundException($"Directory not found: {FullPath}"); + } + } + + public void CopyTo(DirectoryPath destinationDir, bool recursive = true) + { + ThrowIfNotExists(); + + // Cache directories before we start copying + var dirs = EnumerateDirectories().ToList(); + + destinationDir.Create(); + + // Get the files in the source directory and copy to the destination directory + foreach (var file in EnumerateFiles()) + { + var targetFilePath = destinationDir.JoinFile(file.Name); + file.CopyTo(targetFilePath); + } + + // If recursive and copying subdirectories, recursively call this method + if (recursive) + { + foreach (var subDir in dirs) + { + var targetDirectory = destinationDir.JoinDir(subDir.Name); + subDir.CopyTo(targetDirectory); + } + } + } + + public async Task CopyToAsync(DirectoryPath destinationDir, bool recursive = true) + { + ThrowIfNotExists(); + + // Cache directories before we start copying + var dirs = EnumerateDirectories().ToList(); + + destinationDir.Create(); + + // Get the files in the source directory and copy to the destination directory + foreach (var file in EnumerateFiles()) + { + var targetFilePath = destinationDir.JoinFile(file.Name); + await file.CopyToAsync(targetFilePath).ConfigureAwait(false); + } + + // If recursive and copying subdirectories, recursively call this method + if (recursive) + { + foreach (var subDir in dirs) + { + var targetDirectory = destinationDir.JoinDir(subDir.Name); + await subDir.CopyToAsync(targetDirectory).ConfigureAwait(false); + } + } + } + + /// + /// Move the directory to a destination path. + /// + public DirectoryPath MoveTo(DirectoryPath destinationDir) + { + Info.MoveTo(destinationDir.FullPath); + // Return the new path + return destinationDir; + } + + /// + /// Move the file to a target path. + /// + public async Task MoveToAsync(DirectoryPath destinationDir) + { + await Task.Run(() => Info.MoveTo(destinationDir.FullPath)).ConfigureAwait(false); + // Return the new path + return destinationDir; + } + + /// + /// Move the directory to a destination path as a subfolder with the current name. + /// + public async Task MoveToDirectoryAsync(DirectoryPath destinationParentDir) + { + await Task.Run(() => Info.MoveTo(destinationParentDir.JoinDir(Name))).ConfigureAwait(false); + // Return the new path + return destinationParentDir.JoinDir(this); + } + /// /// Join with other paths to form a new directory path. /// @@ -154,6 +255,19 @@ public IEnumerable EnumerateDirectories( Info.EnumerateDirectories(searchPattern, searchOption) .Select(directory => new DirectoryPath(directory)); + /// + /// Return a new with the given file name. + /// + public DirectoryPath WithName(string directoryName) + { + if (Path.GetDirectoryName(FullPath) is { } directory && !string.IsNullOrWhiteSpace(directory)) + { + return new DirectoryPath(directory, directoryName); + } + + return new DirectoryPath(directoryName); + } + public override string ToString() => FullPath; /// diff --git a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs index 494e3aa5e..88df4bf07 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs @@ -14,6 +14,9 @@ public partial class FilePath : FileSystemPath, IPathObject [JsonIgnore] public FileInfo Info => _info ??= new FileInfo(FullPath); + [JsonIgnore] + FileSystemInfo IPathObject.Info => Info; + [JsonIgnore] public bool IsSymbolicLink { @@ -71,6 +74,11 @@ public FilePath(FileSystemPath path) 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/FileInterfaces/FileSystemPath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FileSystemPath.cs index 8fc044911..3a99e9f38 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/FileSystemPath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/FileSystemPath.cs @@ -1,6 +1,10 @@ -namespace StabilityMatrix.Core.Models.FileInterfaces; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; -public class FileSystemPath : IEquatable, IEquatable, IFormattable +namespace StabilityMatrix.Core.Models.FileInterfaces; + +[PublicAPI] +public class FileSystemPath : IEquatable, IFormattable { public string FullPath { get; } @@ -15,6 +19,7 @@ protected FileSystemPath(FileSystemPath path) protected FileSystemPath(params string[] paths) : this(Path.Combine(paths)) { } + /// public override string ToString() { return FullPath; @@ -35,33 +40,79 @@ protected virtual string ToString(string? format, IFormatProvider? formatProvide return FullPath; } + public static bool operator ==(FileSystemPath? left, FileSystemPath? right) + { + return Equals(left, right); + } + + public static bool operator !=(FileSystemPath? left, FileSystemPath? right) + { + return !Equals(left, right); + } + + /// public bool Equals(FileSystemPath? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return FullPath == other.FullPath; + + return string.Equals( + GetNormalizedPath(FullPath), + GetNormalizedPath(other.FullPath), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal + ); } - public bool Equals(string? other) + /// + public override bool Equals(object? obj) { - return string.Equals(FullPath, other); + if (ReferenceEquals(null, obj)) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (GetType() != obj.GetType()) + return false; + return Equals((FileSystemPath)obj); } - public override bool Equals(object? obj) + /// + /// Normalize a path to a consistent format for comparison. + /// + /// Path to normalize. + /// Normalized path. + [return: NotNullIfNotNull(nameof(path))] + private static string? GetNormalizedPath(string? path) { - return obj switch + // Return null or empty paths as-is + if (string.IsNullOrEmpty(path)) { - FileSystemPath path => Equals(path), - string path => Equals(path), - _ => false - }; + return path; + } + + if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri)) + { + if (uri.IsAbsoluteUri) + { + path = uri.LocalPath; + } + } + + // Get full path if possible, ignore errors like invalid chars or too long + try + { + path = Path.GetFullPath(path); + } + catch (SystemException) { } + + return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + /// public override int GetHashCode() { - return FullPath.GetHashCode(); + return HashCode.Combine(GetType().GetHashCode(), FullPath.GetHashCode()); } // Implicit conversions to and from string diff --git a/StabilityMatrix.Core/Models/FileInterfaces/IPathObject.cs b/StabilityMatrix.Core/Models/FileInterfaces/IPathObject.cs index 81011f11c..119b01d3e 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/IPathObject.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/IPathObject.cs @@ -4,19 +4,22 @@ public interface IPathObject { /// Full path of the file system object. string FullPath { get; } - + + /// Info of the file system object. + FileSystemInfo Info { get; } + /// Name of the file system object. string Name { get; } - + /// Whether the file system object is a symbolic link or junction. bool IsSymbolicLink { get; } /// Gets the size of the file system object. long GetSize(); - + /// Gets the size of the file system object asynchronously. Task GetSizeAsync() => Task.Run(GetSize); - + /// Whether the file system object exists. bool Exists { get; } diff --git a/StabilityMatrix.Core/Models/GenerationParameters.cs b/StabilityMatrix.Core/Models/GenerationParameters.cs index c0b5aa9b5..46f7459a6 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/GitVersion.cs b/StabilityMatrix.Core/Models/GitVersion.cs new file mode 100644 index 000000000..640dcf813 --- /dev/null +++ b/StabilityMatrix.Core/Models/GitVersion.cs @@ -0,0 +1,40 @@ +namespace StabilityMatrix.Core.Models; + +/// +/// Union of either Tag or Branch + CommitSha. +/// +public record GitVersion : IFormattable +{ + public string? Tag { get; init; } + + public string? Branch { get; init; } + + public string? CommitSha { get; init; } + + /// + public override string ToString() + { + if (!string.IsNullOrEmpty(Tag)) + { + return Tag; + } + + if (!string.IsNullOrEmpty(Branch) && !string.IsNullOrEmpty(CommitSha)) + { + return $"{Branch}@{CommitSha[..7]}"; + } + + if (!string.IsNullOrEmpty(Branch)) + { + return Branch; + } + + return !string.IsNullOrEmpty(CommitSha) ? CommitSha[..7] : ""; + } + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + return ToString(); + } +} diff --git a/StabilityMatrix.Core/Models/IHandleNavigation.cs b/StabilityMatrix.Core/Models/IHandleNavigation.cs new file mode 100644 index 000000000..14011508e --- /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/InstalledPackage.cs b/StabilityMatrix.Core/Models/InstalledPackage.cs index 8f2d5c333..de19eb9e3 100644 --- a/StabilityMatrix.Core/Models/InstalledPackage.cs +++ b/StabilityMatrix.Core/Models/InstalledPackage.cs @@ -57,6 +57,8 @@ public class InstalledPackage : IJsonOnDeserialized public bool UseSharedOutputFolder { get; set; } + public List? ExtraExtensionManifestUrls { get; set; } + /// /// Get the launch args host option value. /// @@ -239,7 +241,11 @@ public void OnDeserialized() if (string.IsNullOrWhiteSpace(InstalledBranch) && !string.IsNullOrWhiteSpace(PackageVersion)) { // release mode - Version = new InstalledPackageVersion { InstalledReleaseVersion = PackageVersion, IsPrerelease = false }; + Version = new InstalledPackageVersion + { + InstalledReleaseVersion = PackageVersion, + IsPrerelease = false + }; } else if (!string.IsNullOrWhiteSpace(PackageVersion)) { diff --git a/StabilityMatrix.Core/Models/PackageModification/IPackageModificationRunner.cs b/StabilityMatrix.Core/Models/PackageModification/IPackageModificationRunner.cs index 6037b1063..0c99500be 100644 --- a/StabilityMatrix.Core/Models/PackageModification/IPackageModificationRunner.cs +++ b/StabilityMatrix.Core/Models/PackageModification/IPackageModificationRunner.cs @@ -1,18 +1,40 @@ -using StabilityMatrix.Core.Models.Progress; +using System.Diagnostics.CodeAnalysis; +using StabilityMatrix.Core.Models.Progress; namespace StabilityMatrix.Core.Models.PackageModification; public interface IPackageModificationRunner { Task ExecuteSteps(IReadOnlyList steps); - bool IsRunning { get; set; } + + bool IsRunning { get; } + + [MemberNotNullWhen(true, nameof(Exception))] + bool Failed { get; } + + Exception? Exception { get; } + ProgressReport CurrentProgress { get; set; } + IPackageStep? CurrentStep { get; set; } + event EventHandler? ProgressChanged; + + event EventHandler? Completed; + List ConsoleOutput { get; } + Guid Id { get; } + bool ShowDialogOnStart { get; init; } + bool HideCloseButton { get; init; } + + string? ModificationCompleteTitle { get; init; } + string? ModificationCompleteMessage { get; init; } - bool Failed { get; set; } + + string? ModificationFailedTitle { get; init; } + + string? ModificationFailedMessage { get; init; } } diff --git a/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs b/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs new file mode 100644 index 000000000..0e3018f72 --- /dev/null +++ b/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs @@ -0,0 +1,24 @@ +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Core.Models.PackageModification; + +public class InstallExtensionStep( + IPackageExtensionManager extensionManager, + InstalledPackage installedPackage, + PackageExtension packageExtension, + PackageExtensionVersion? extensionVersion = null +) : IPackageStep +{ + public Task ExecuteAsync(IProgress? progress = null) + { + return extensionManager.InstallExtensionAsync( + packageExtension, + installedPackage, + extensionVersion, + progress + ); + } + + public string ProgressTitle => $"Installing Extension {packageExtension.Title}"; +} diff --git a/StabilityMatrix.Core/Models/PackageModification/PackageModificationRunner.cs b/StabilityMatrix.Core/Models/PackageModification/PackageModificationRunner.cs index 0adbe468e..bb57b56ef 100644 --- a/StabilityMatrix.Core/Models/PackageModification/PackageModificationRunner.cs +++ b/StabilityMatrix.Core/Models/PackageModification/PackageModificationRunner.cs @@ -1,4 +1,5 @@ -using StabilityMatrix.Core.Models.Progress; +using System.Diagnostics.CodeAnalysis; +using StabilityMatrix.Core.Models.Progress; namespace StabilityMatrix.Core.Models.PackageModification; @@ -18,54 +19,88 @@ public async Task ExecuteSteps(IReadOnlyList steps) }); IsRunning = true; - foreach (var step in steps) + + try { - CurrentStep = step; - try + foreach (var step in steps) { - await step.ExecuteAsync(progress).ConfigureAwait(false); + CurrentStep = step; + try + { + await step.ExecuteAsync(progress).ConfigureAwait(false); + } + catch (Exception e) + { + var failedMessage = string.IsNullOrWhiteSpace(ModificationFailedMessage) + ? $"Error: {e}" + : ModificationFailedMessage + $" ({e})"; + + progress.Report( + new ProgressReport( + 1f, + title: ModificationFailedTitle, + message: failedMessage, + isIndeterminate: false + ) + ); + + Exception = e; + Failed = true; + return; + } } - catch (Exception e) + + if (!Failed) { progress.Report( new ProgressReport( 1f, - title: "Error modifying package", - message: $"Error: {e}", + title: ModificationCompleteTitle, + message: ModificationCompleteMessage, isIndeterminate: false ) ); - Failed = true; - break; } } - - if (!Failed) + finally { - progress.Report( - new ProgressReport( - 1f, - message: ModificationCompleteMessage ?? "Package Install Complete", - isIndeterminate: false - ) - ); + IsRunning = false; + OnCompleted(); } - - IsRunning = false; } public bool HideCloseButton { get; init; } - public string? ModificationCompleteMessage { get; init; } + public bool ShowDialogOnStart { get; init; } - public bool IsRunning { get; set; } - public bool Failed { get; set; } + public string? ModificationCompleteTitle { get; init; } = "Install Complete"; + + public string? ModificationCompleteMessage { get; init; } + + public string? ModificationFailedTitle { get; init; } = "Install Failed"; + + public string? ModificationFailedMessage { get; init; } + + public bool IsRunning { get; private set; } + + [MemberNotNullWhen(true, nameof(Exception))] + public bool Failed { get; private set; } + + public Exception? Exception { get; set; } + public ProgressReport CurrentProgress { get; set; } + public IPackageStep? CurrentStep { get; set; } + public List ConsoleOutput { get; } = new(); + public Guid Id { get; } = Guid.NewGuid(); public event EventHandler? ProgressChanged; + public event EventHandler? Completed; + protected virtual void OnProgressChanged(ProgressReport e) => ProgressChanged?.Invoke(this, e); + + protected virtual void OnCompleted() => Completed?.Invoke(this, this); } diff --git a/StabilityMatrix.Core/Models/PackageModification/SetupPrerequisitesStep.cs b/StabilityMatrix.Core/Models/PackageModification/SetupPrerequisitesStep.cs index c5b480405..107e75a79 100644 --- a/StabilityMatrix.Core/Models/PackageModification/SetupPrerequisitesStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/SetupPrerequisitesStep.cs @@ -1,4 +1,5 @@ using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Python; @@ -8,36 +9,23 @@ public class SetupPrerequisitesStep : IPackageStep { private readonly IPrerequisiteHelper prerequisiteHelper; private readonly IPyRunner pyRunner; + private readonly BasePackage package; - public SetupPrerequisitesStep(IPrerequisiteHelper prerequisiteHelper, IPyRunner pyRunner) + public SetupPrerequisitesStep( + IPrerequisiteHelper prerequisiteHelper, + IPyRunner pyRunner, + BasePackage package + ) { this.prerequisiteHelper = prerequisiteHelper; this.pyRunner = pyRunner; + this.package = package; } public async Task ExecuteAsync(IProgress? progress = null) { - // git, vcredist, etc... - await prerequisiteHelper.InstallAllIfNecessary(progress).ConfigureAwait(false); - - // python stuff - if (!PyRunner.PipInstalled || !PyRunner.VenvInstalled) - { - progress?.Report( - new ProgressReport(-1f, "Installing Python prerequisites...", isIndeterminate: true) - ); - - await pyRunner.Initialize().ConfigureAwait(false); - - if (!PyRunner.PipInstalled) - { - await pyRunner.SetupPip().ConfigureAwait(false); - } - if (!PyRunner.VenvInstalled) - { - await pyRunner.InstallPackage("virtualenv").ConfigureAwait(false); - } - } + // package and platform-specific requirements install + await prerequisiteHelper.InstallPackageRequirements(package, progress).ConfigureAwait(false); } public string ProgressTitle => "Installing prerequisites..."; diff --git a/StabilityMatrix.Core/Models/PackageModification/UninstallExtensionStep.cs b/StabilityMatrix.Core/Models/PackageModification/UninstallExtensionStep.cs new file mode 100644 index 000000000..068bd2f04 --- /dev/null +++ b/StabilityMatrix.Core/Models/PackageModification/UninstallExtensionStep.cs @@ -0,0 +1,18 @@ +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Core.Models.PackageModification; + +public class UninstallExtensionStep( + IPackageExtensionManager extensionManager, + InstalledPackage installedPackage, + InstalledPackageExtension packageExtension +) : IPackageStep +{ + public Task ExecuteAsync(IProgress? progress = null) + { + return extensionManager.UninstallExtensionAsync(packageExtension, installedPackage, progress); + } + + public string ProgressTitle => $"Uninstalling Extension {packageExtension.Title}"; +} diff --git a/StabilityMatrix.Core/Models/PackageModification/UpdateExtensionStep.cs b/StabilityMatrix.Core/Models/PackageModification/UpdateExtensionStep.cs new file mode 100644 index 000000000..df8ba8b52 --- /dev/null +++ b/StabilityMatrix.Core/Models/PackageModification/UpdateExtensionStep.cs @@ -0,0 +1,24 @@ +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Core.Models.PackageModification; + +public class UpdateExtensionStep( + IPackageExtensionManager extensionManager, + InstalledPackage installedPackage, + InstalledPackageExtension installedExtension, + PackageExtensionVersion? extensionVersion = null +) : IPackageStep +{ + public Task ExecuteAsync(IProgress? progress = null) + { + return extensionManager.UpdateExtensionAsync( + installedExtension, + installedPackage, + extensionVersion, + progress + ); + } + + public string ProgressTitle => $"Updating Extension {installedExtension.Title}"; +} diff --git a/StabilityMatrix.Core/Models/PackageModification/UpdatePackageStep.cs b/StabilityMatrix.Core/Models/PackageModification/UpdatePackageStep.cs index be996cfec..11a8e1116 100644 --- a/StabilityMatrix.Core/Models/PackageModification/UpdatePackageStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/UpdatePackageStep.cs @@ -27,8 +27,7 @@ BasePackage basePackage public async Task ExecuteAsync(IProgress? progress = null) { - var torchVersion = - installedPackage.PreferredTorchVersion ?? basePackage.GetRecommendedTorchVersion(); + var torchVersion = installedPackage.PreferredTorchVersion ?? basePackage.GetRecommendedTorchVersion(); void OnConsoleOutput(ProcessOutput output) { @@ -45,9 +44,9 @@ void OnConsoleOutput(ProcessOutput output) ) .ConfigureAwait(false); - settingsManager.UpdatePackageVersionNumber(installedPackage.Id, updateResult); await using (settingsManager.BeginTransaction()) { + installedPackage.Version = updateResult; installedPackage.UpdateAvailable = false; } } diff --git a/StabilityMatrix.Core/Models/PackagePrerequisite.cs b/StabilityMatrix.Core/Models/PackagePrerequisite.cs new file mode 100644 index 000000000..4d3441735 --- /dev/null +++ b/StabilityMatrix.Core/Models/PackagePrerequisite.cs @@ -0,0 +1,11 @@ +namespace StabilityMatrix.Core.Models; + +public enum PackagePrerequisite +{ + Python310, + VcRedist, + Git, + Node, + Dotnet7, + Dotnet8 +} diff --git a/StabilityMatrix.Core/Models/PackageType.cs b/StabilityMatrix.Core/Models/PackageType.cs new file mode 100644 index 000000000..92a2a847e --- /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/A3WebUI.cs b/StabilityMatrix.Core/Models/Packages/A3WebUI.cs index c26f52b5e..75130ea8e 100644 --- a/StabilityMatrix.Core/Models/Packages/A3WebUI.cs +++ b/StabilityMatrix.Core/Models/Packages/A3WebUI.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using NLog; @@ -7,6 +8,7 @@ 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; @@ -146,7 +148,8 @@ IPrerequisiteHelper prerequisiteHelper Name = "No Half", Type = LaunchOptionType.Bool, Description = "Do not switch the model to 16-bit floats", - InitialValue = HardwareHelper.PreferRocm() || HardwareHelper.PreferDirectML(), + InitialValue = + HardwareHelper.PreferRocm() || HardwareHelper.PreferDirectML() || Compat.IsMacOS, Options = ["--no-half"] }, new() @@ -169,12 +172,14 @@ IPrerequisiteHelper prerequisiteHelper new[] { SharedFolderMethod.Symlink, SharedFolderMethod.None }; public override IEnumerable AvailableTorchVersions => - new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.Rocm }; + new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.Rocm, TorchVersion.Mps }; public override string MainBranch => "master"; public override string OutputFolderName => "outputs"; + public override IPackageExtensionManager ExtensionManager => new A3WebUiExtensionManager(this); + public override async Task InstallPackage( string installLocation, TorchVersion torchVersion, @@ -186,23 +191,25 @@ public override async Task InstallPackage( { progress?.Report(new ProgressReport(-1f, "Setting up venv", isIndeterminate: true)); - var venvRunner = await SetupVenv(installLocation, forceRecreate: true).ConfigureAwait(false); + var venvPath = Path.Combine(installLocation, "venv"); + var exists = Directory.Exists(venvPath); + var venvRunner = await SetupVenv(installLocation, forceRecreate: true).ConfigureAwait(false); await venvRunner.PipInstall("--upgrade pip wheel", onConsoleOutput).ConfigureAwait(false); progress?.Report(new ProgressReport(-1f, "Installing requirements...", isIndeterminate: true)); var requirements = new FilePath(installLocation, "requirements_versions.txt"); - var pipArgs = new PipInstallArgs() - .WithTorch("==2.0.1") - .WithTorchVision("==0.15.2") + .WithTorch("==2.1.2") + .WithTorchVision("==0.16.2") .WithTorchExtraIndex( torchVersion switch { TorchVersion.Cpu => "cpu", - TorchVersion.Cuda => "cu118", - TorchVersion.Rocm => "rocm5.1.1", + TorchVersion.Cuda => "cu121", + TorchVersion.Rocm => "rocm5.6", + TorchVersion.Mps => "nightly/cpu", _ => throw new ArgumentOutOfRangeException(nameof(torchVersion), torchVersion, null) } ) @@ -213,7 +220,7 @@ await requirements.ReadAllTextAsync().ConfigureAwait(false), if (torchVersion == TorchVersion.Cuda) { - pipArgs = pipArgs.WithXFormers("==0.0.20"); + pipArgs = pipArgs.WithXFormers("==0.0.23.post1"); } // v1.6.0 needs a httpx qualifier to fix a gradio issue @@ -222,6 +229,15 @@ await requirements.ReadAllTextAsync().ConfigureAwait(false), pipArgs = pipArgs.AddArg("httpx==0.24.1"); } + // Add jsonmerge to fix https://github.com/AUTOMATIC1111/stable-diffusion-webui/issues/12482 + pipArgs = pipArgs.AddArg("jsonmerge"); + + if (exists) + { + pipArgs = pipArgs.AddArg("--upgrade"); + pipArgs = pipArgs.AddArg("--force-reinstall"); + } + await venvRunner.PipInstall(pipArgs, onConsoleOutput).ConfigureAwait(false); progress?.Report(new ProgressReport(-1f, "Updating configuration", isIndeterminate: true)); @@ -267,4 +283,46 @@ void HandleConsoleOutput(ProcessOutput s) VenvRunner.RunDetached(args.TrimEnd(), HandleConsoleOutput, OnExit); } + + private class A3WebUiExtensionManager(A3WebUI package) + : GitPackageExtensionManager(package.PrerequisiteHelper) + { + public override string RelativeInstallDirectory => "extensions"; + + public override IEnumerable DefaultManifests => + [ + new ExtensionManifest( + new Uri( + "https://raw.githubusercontent.com/AUTOMATIC1111/stable-diffusion-webui-extensions/master/index.json" + ) + ) + ]; + + public override async Task> GetManifestExtensionsAsync( + ExtensionManifest manifest, + CancellationToken cancellationToken = default + ) + { + try + { + // Get json + var content = await package + .DownloadService.GetContentAsync(manifest.Uri.ToString(), cancellationToken) + .ConfigureAwait(false); + + // Parse json + var jsonManifest = JsonSerializer.Deserialize( + content, + A1111ExtensionManifestSerializerContext.Default.Options + ); + + return jsonManifest?.GetPackageExtensions() ?? Enumerable.Empty(); + } + catch (Exception e) + { + Logger.Error(e, "Failed to get extensions from manifest"); + return Enumerable.Empty(); + } + } + } } diff --git a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs index 373c19f45..86a103c58 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; @@ -75,11 +76,13 @@ public override async Task GetLatestVersion(bool { if (ShouldIgnoreReleases) { + var commits = await GithubApi.GetAllCommits(Author, Name, MainBranch).ConfigureAwait(false); return new DownloadPackageVersionOptions { IsLatest = true, IsPrerelease = false, - BranchName = MainBranch + BranchName = MainBranch, + CommitHash = commits?.FirstOrDefault()?.Sha ?? "unknown" }; } @@ -147,10 +150,24 @@ public async Task SetupVenv( 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) @@ -160,6 +177,47 @@ public async Task SetupVenv( return VenvRunner; } + /// + /// Like , but does not set the property. + /// Returns a new instance. + /// + public async Task SetupVenvPure( + string installedPackagePath, + string venvName = "venv", + bool forceRecreate = false, + Action? onConsoleOutput = null + ) + { + var venvPath = Path.Combine(installedPackagePath, venvName); + + // 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; + } + + var venvRunner = new PyVenvRunner(venvPath) + { + WorkingDirectory = installedPackagePath, + EnvironmentVariables = env + }; + + if (!venvRunner.Exists() || forceRecreate) + { + await venvRunner.Setup(forceRecreate, onConsoleOutput).ConfigureAwait(false); + } + + return venvRunner; + } + public override async Task> GetReleaseTags() { var allReleases = await GithubApi.GetAllReleases(Author, Name).ConfigureAwait(false); diff --git a/StabilityMatrix.Core/Models/Packages/BasePackage.cs b/StabilityMatrix.Core/Models/Packages/BasePackage.cs index 063693e7d..368cf2bcb 100644 --- a/StabilityMatrix.Core/Models/Packages/BasePackage.cs +++ b/StabilityMatrix.Core/Models/Packages/BasePackage.cs @@ -1,8 +1,10 @@ -using Octokit; +using System.Diagnostics.CodeAnalysis; +using Octokit; 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 +49,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 +89,20 @@ public abstract Task Update( 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 +130,11 @@ public virtual TorchVersion GetRecommendedTorchVersion() return TorchVersion.DirectMl; } + if (Compat.IsMacOS && Compat.IsArm && AvailableTorchVersions.Contains(TorchVersion.Mps)) + { + return TorchVersion.Mps; + } + return TorchVersion.Cpu; } @@ -141,8 +159,23 @@ public virtual TorchVersion GetRecommendedTorchVersion() public abstract Dictionary>? SharedFolders { get; } public abstract Dictionary>? SharedOutputFolders { get; } + /// + /// If defined, this package supports extensions using this manager. + /// + public virtual IPackageExtensionManager? ExtensionManager => null; + + /// + /// True if this package supports extensions. + /// + [MemberNotNullWhen(true, nameof(ExtensionManager))] + public virtual bool SupportsExtensions => ExtensionManager is not null; + 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 +186,12 @@ public virtual TorchVersion GetRecommendedTorchVersion() 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; + + public virtual IEnumerable Prerequisites => + [PackagePrerequisite.Git, PackagePrerequisite.Python310, PackagePrerequisite.VcRedist]; protected async Task InstallCudaTorch( PyVenvRunner venvRunner, @@ -166,10 +204,10 @@ protected async Task InstallCudaTorch( await venvRunner .PipInstall( new PipInstallArgs() - .WithTorch("==2.0.1") - .WithTorchVision("==0.15.2") - .WithXFormers("==0.0.20") - .WithTorchExtraIndex("cu118"), + .WithTorch("==2.1.2") + .WithTorchVision("==0.16.2") + .WithXFormers("==0.0.23post1") + .WithTorchExtraIndex("cu121"), onConsoleOutput ) .ConfigureAwait(false); @@ -194,6 +232,9 @@ protected Task InstallCpuTorch( { 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.1.2").WithTorchVision(), + onConsoleOutput + ); } } diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index 81a415c4a..d64c6b82b 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -1,11 +1,16 @@ using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using NLog; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Exceptions; +using StabilityMatrix.Core.Extensions; 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 +119,36 @@ IPrerequisiteHelper prerequisiteHelper { 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 +173,14 @@ IPrerequisiteHelper prerequisiteHelper 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 +199,9 @@ public override async Task InstallPackage( 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 +221,12 @@ public override async Task InstallPackage( 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 +284,23 @@ void HandleConsoleOutput(ProcessOutput s) } } - 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 +338,9 @@ private async Task SetupModelFoldersConfig(DirectoryPath installDirectory) 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 +391,11 @@ private async Task SetupModelFoldersConfig(DirectoryPath installDirectory) { "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,9 +459,154 @@ private static async Task RemoveConfigSection(DirectoryPath installDirectory) 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); } + + public override IPackageExtensionManager ExtensionManager => new ComfyExtensionManager(this); + + private class ComfyExtensionManager(ComfyUI package) + : GitPackageExtensionManager(package.PrerequisiteHelper) + { + public override string RelativeInstallDirectory => "custom_nodes"; + + public override IEnumerable DefaultManifests => + [ + new ExtensionManifest( + new Uri("https://cdn.jsdelivr.net/gh/ltdrdata/ComfyUI-Manager/custom-node-list.json") + ) + ]; + + public override async Task> GetManifestExtensionsAsync( + ExtensionManifest manifest, + CancellationToken cancellationToken = default + ) + { + try + { + // Get json + var content = await package + .DownloadService.GetContentAsync(manifest.Uri.ToString(), cancellationToken) + .ConfigureAwait(false); + + // Parse json + var jsonManifest = JsonSerializer.Deserialize( + content, + ComfyExtensionManifestSerializerContext.Default.Options + ); + + return jsonManifest?.GetPackageExtensions() ?? Enumerable.Empty(); + } + catch (Exception e) + { + Logger.Error(e, "Failed to get package extensions"); + return Enumerable.Empty(); + } + } + + /// + public override async Task InstallExtensionAsync( + PackageExtension extension, + InstalledPackage installedPackage, + PackageExtensionVersion? version = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + await base.InstallExtensionAsync( + extension, + installedPackage, + version, + progress, + cancellationToken + ) + .ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + var cloneRoot = new DirectoryPath(installedPackage.FullPath!, RelativeInstallDirectory); + + var installedDirs = extension + .Files.Select(uri => uri.Segments.LastOrDefault()) + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => cloneRoot.JoinDir(path!)) + .Where(dir => dir.Exists); + + foreach (var installedDir in installedDirs) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Install requirements.txt if found + if (installedDir.JoinFile("requirements.txt") is { Exists: true } requirementsFile) + { + var requirementsContent = await requirementsFile + .ReadAllTextAsync(cancellationToken) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(requirementsContent)) + { + progress?.Report( + new ProgressReport( + 0f, + $"Installing requirements.txt for {installedDir.Name}", + isIndeterminate: true + ) + ); + + await using var venvRunner = await package + .SetupVenvPure(installedPackage.FullPath!) + .ConfigureAwait(false); + + var pipArgs = new PipInstallArgs().WithParsedFromRequirementsTxt(requirementsContent); + + await venvRunner + .PipInstall(pipArgs, progress.AsProcessOutputHandler()) + .ConfigureAwait(false); + + progress?.Report( + new ProgressReport(1f, $"Installed requirements.txt for {installedDir.Name}") + ); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Run install.py if found + if (installedDir.JoinFile("install.py") is { Exists: true } installScript) + { + progress?.Report( + new ProgressReport( + 0f, + $"Running install.py for {installedDir.Name}", + isIndeterminate: true + ) + ); + + await using var venvRunner = await package + .SetupVenvPure(installedPackage.FullPath!) + .ConfigureAwait(false); + + venvRunner.WorkingDirectory = installScript.Directory; + + venvRunner.RunDetached(["install.py"], progress.AsProcessOutputHandler()); + + await venvRunner.Process.WaitUntilOutputEOF(cancellationToken).ConfigureAwait(false); + await venvRunner.Process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (venvRunner.Process.HasExited && venvRunner.Process.ExitCode != 0) + { + throw new ProcessException( + $"install.py for {installedDir.Name} exited with code {venvRunner.Process.ExitCode}" + ); + } + + progress?.Report(new ProgressReport(1f, $"Ran launch.py for {installedDir.Name}")); + } + } + } + } } diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/A1111ExtensionManifest.cs b/StabilityMatrix.Core/Models/Packages/Extensions/A1111ExtensionManifest.cs new file mode 100644 index 000000000..e41260ccc --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/A1111ExtensionManifest.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public record A1111ExtensionManifest +{ + public required IEnumerable Extensions { get; init; } + + public IEnumerable GetPackageExtensions() + { + return Extensions.Select( + x => + new PackageExtension + { + Author = x.FullName?.Split('/').FirstOrDefault() ?? "Unknown", + Title = x.Name, + Reference = x.Url, + Files = [x.Url], + Description = x.Description, + InstallType = "git-clone" + } + ); + } + + public record ManifestEntry + { + public string? FullName { get; init; } + + public required string Name { get; init; } + + public required Uri Url { get; init; } + + public string? Description { get; init; } + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(A1111ExtensionManifest))] +internal partial class A1111ExtensionManifestSerializerContext : JsonSerializerContext; diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs b/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs new file mode 100644 index 000000000..d4419b46e --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public record ComfyExtensionManifest +{ + public required IEnumerable CustomNodes { get; init; } + + public IEnumerable GetPackageExtensions() + { + return CustomNodes.Select( + x => + new PackageExtension + { + Author = x.Author, + Title = x.Title, + Reference = x.Reference, + Files = x.Files, + Description = x.Description, + InstallType = x.InstallType + } + ); + } + + public record ManifestEntry + { + public required string Author { get; init; } + + public required string Title { get; init; } + + public required Uri Reference { get; init; } + + public required IEnumerable Files { get; init; } + + public string? Description { get; init; } + + public string? InstallType { get; init; } + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(ComfyExtensionManifest))] +internal partial class ComfyExtensionManifestSerializerContext : JsonSerializerContext; diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionManifest.cs b/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionManifest.cs new file mode 100644 index 000000000..57afcdb45 --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionManifest.cs @@ -0,0 +1,3 @@ +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public record ExtensionManifest(Uri Uri); diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/GitPackageExtensionManager.cs b/StabilityMatrix.Core/Models/Packages/Extensions/GitPackageExtensionManager.cs new file mode 100644 index 000000000..7f20a5501 --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/GitPackageExtensionManager.cs @@ -0,0 +1,237 @@ +using KGySoft.CoreLibraries; +using NLog; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Processes; + +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public abstract class GitPackageExtensionManager(IPrerequisiteHelper prerequisiteHelper) + : IPackageExtensionManager +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public abstract string RelativeInstallDirectory { get; } + + public virtual IEnumerable DefaultManifests { get; } = + Enumerable.Empty(); + + protected virtual IEnumerable IndexRelativeDirectories => [RelativeInstallDirectory]; + + public abstract Task> GetManifestExtensionsAsync( + ExtensionManifest manifest, + CancellationToken cancellationToken = default + ); + + /// + Task> IPackageExtensionManager.GetManifestExtensionsAsync( + ExtensionManifest manifest, + CancellationToken cancellationToken + ) + { + return GetManifestExtensionsAsync(manifest, cancellationToken); + } + + protected virtual IEnumerable GetManifests(InstalledPackage installedPackage) + { + if (installedPackage.ExtraExtensionManifestUrls is not { } customUrls) + { + return DefaultManifests; + } + + var manifests = DefaultManifests.ToList(); + + foreach (var url in customUrls) + { + if (!string.IsNullOrEmpty(url) && Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + manifests.Add(new ExtensionManifest(uri)); + } + } + + return manifests; + } + + /// + IEnumerable IPackageExtensionManager.GetManifests(InstalledPackage installedPackage) + { + return GetManifests(installedPackage); + } + + /// + public virtual async Task> GetInstalledExtensionsAsync( + InstalledPackage installedPackage, + CancellationToken cancellationToken = default + ) + { + if (installedPackage.FullPath is not { } packagePath) + { + return Enumerable.Empty(); + } + + var extensions = new List(); + + // Search for installed extensions in the package's index directories. + foreach ( + var indexDirectory in IndexRelativeDirectories.Select( + path => new DirectoryPath(packagePath, path) + ) + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Skip directory if not exists + if (!indexDirectory.Exists) + { + continue; + } + + // Check subdirectories of the index directory + foreach (var subDirectory in indexDirectory.EnumerateDirectories()) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Skip if not valid git repository + if (await prerequisiteHelper.CheckIsGitRepository(subDirectory).ConfigureAwait(false) != true) + continue; + + // Get git version + var version = await prerequisiteHelper + .GetGitRepositoryVersion(subDirectory) + .ConfigureAwait(false); + + // Get git remote + var remoteUrlResult = await prerequisiteHelper + .GetGitRepositoryRemoteOriginUrl(subDirectory) + .ConfigureAwait(false); + + extensions.Add( + new InstalledPackageExtension + { + Paths = [subDirectory], + Version = new PackageExtensionVersion + { + Tag = version.Tag, + Branch = version.Branch, + CommitSha = version.CommitSha + }, + GitRepositoryUrl = remoteUrlResult.IsSuccessExitCode + ? remoteUrlResult.StandardOutput?.Trim() + : null + } + ); + } + } + + return extensions; + } + + /// + public virtual async Task InstallExtensionAsync( + PackageExtension extension, + InstalledPackage installedPackage, + PackageExtensionVersion? version = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(installedPackage.FullPath); + + // Ensure type + if (extension.InstallType?.ToLowerInvariant() != "git-clone") + { + throw new ArgumentException( + $"Extension must have install type 'git-clone' but has '{extension.InstallType}'.", + nameof(extension) + ); + } + + // Git clone all files + var cloneRoot = new DirectoryPath(installedPackage.FullPath, RelativeInstallDirectory); + + foreach (var repositoryUri in extension.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + + progress?.Report(new ProgressReport(0f, $"Cloning {repositoryUri}", isIndeterminate: true)); + + await prerequisiteHelper + .CloneGitRepository(cloneRoot, repositoryUri.ToString(), version) + .ConfigureAwait(false); + + progress?.Report(new ProgressReport(1f, $"Cloned {repositoryUri}")); + } + } + + /// + public virtual async Task UpdateExtensionAsync( + InstalledPackageExtension installedExtension, + InstalledPackage installedPackage, + PackageExtensionVersion? version = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(installedPackage.FullPath); + + foreach (var repoPath in installedExtension.Paths.OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Check git + if (!await prerequisiteHelper.CheckIsGitRepository(repoPath.FullPath).ConfigureAwait(false)) + continue; + + // Get remote url + var remoteUrlResult = await prerequisiteHelper + .GetGitRepositoryRemoteOriginUrl(repoPath.FullPath) + .EnsureSuccessExitCode() + .ConfigureAwait(false); + + progress?.Report( + new ProgressReport(0f, $"Updating git repository {repoPath.Name}", isIndeterminate: true) + ); + + // If version not provided, use current branch + if (version is null) + { + ArgumentNullException.ThrowIfNull(installedExtension.Version?.Branch); + + version = new PackageExtensionVersion { Branch = installedExtension.Version?.Branch }; + } + + await prerequisiteHelper + .UpdateGitRepository(repoPath, remoteUrlResult.StandardOutput!.Trim(), version) + .ConfigureAwait(false); + + progress?.Report(new ProgressReport(1f, $"Updated git repository {repoPath.Name}")); + } + } + + /// + public virtual async Task UninstallExtensionAsync( + InstalledPackageExtension installedExtension, + InstalledPackage installedPackage, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + foreach (var path in installedExtension.Paths.Where(p => p.Exists)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (path is DirectoryPath directoryPath) + { + await directoryPath + .DeleteVerboseAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + else + { + await path.DeleteAsync().ConfigureAwait(false); + } + } + } +} diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/IPackageExtensionManager.cs b/StabilityMatrix.Core/Models/Packages/Extensions/IPackageExtensionManager.cs new file mode 100644 index 000000000..4623b715b --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/IPackageExtensionManager.cs @@ -0,0 +1,95 @@ +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +/// +/// Interface for a package extension manager. +/// +public interface IPackageExtensionManager +{ + /// + /// Default manifests for this extension manager. + /// + IEnumerable DefaultManifests { get; } + + /// + /// Get manifests given an installed package. + /// By default returns . + /// + IEnumerable GetManifests(InstalledPackage installedPackage) + { + return DefaultManifests; + } + + /// + /// Get extensions from the provided manifest. + /// + Task> GetManifestExtensionsAsync( + ExtensionManifest manifest, + CancellationToken cancellationToken = default + ); + + /// + /// Get extensions from all provided manifests. + /// + async Task> GetManifestExtensionsAsync( + IEnumerable manifests, + CancellationToken cancellationToken = default + ) + { + var extensions = Enumerable.Empty(); + + foreach (var manifest in manifests) + { + cancellationToken.ThrowIfCancellationRequested(); + + extensions = extensions.Concat( + await GetManifestExtensionsAsync(manifest, cancellationToken).ConfigureAwait(false) + ); + } + + return extensions; + } + + /// + /// Get all installed extensions for the provided package. + /// + Task> GetInstalledExtensionsAsync( + InstalledPackage installedPackage, + CancellationToken cancellationToken = default + ); + + /// + /// Install an extension to the provided package. + /// + Task InstallExtensionAsync( + PackageExtension extension, + InstalledPackage installedPackage, + PackageExtensionVersion? version = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// + /// Update an installed extension to the provided version. + /// If no version is provided, the latest version will be used. + /// + Task UpdateExtensionAsync( + InstalledPackageExtension installedExtension, + InstalledPackage installedPackage, + PackageExtensionVersion? version = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// + /// Uninstall an installed extension. + /// + Task UninstallExtensionAsync( + InstalledPackageExtension installedExtension, + InstalledPackage installedPackage, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); +} diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/InstalledPackageExtension.cs b/StabilityMatrix.Core/Models/Packages/Extensions/InstalledPackageExtension.cs new file mode 100644 index 000000000..3fc00471f --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/InstalledPackageExtension.cs @@ -0,0 +1,60 @@ +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public record InstalledPackageExtension +{ + /// + /// All folders or files of the extension. + /// + public required IEnumerable Paths { get; init; } + + /// + /// Primary path of the extension. + /// + public IPathObject? PrimaryPath => Paths.FirstOrDefault(); + + /// + /// The version of the extension. + /// + public PackageExtensionVersion? Version { get; init; } + + /// + /// Remote git repository url, if the extension is a git repository. + /// + public string? GitRepositoryUrl { get; init; } + + /// + /// The PackageExtension definition, if available. + /// + public PackageExtension? Definition { get; init; } + + public string Title + { + get + { + if (Definition?.Title is { } title) + { + return title; + } + + if (Paths.FirstOrDefault()?.Name is { } pathName) + { + return pathName; + } + + return ""; + } + } + + /// + /// Path containing PrimaryPath and its parent. + /// + public string DisplayPath => + PrimaryPath switch + { + null => "", + DirectoryPath { Parent: { } parentDir } dir => $"{parentDir.Name}/{dir.Name}", + _ => PrimaryPath.Name + }; +} diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs b/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs new file mode 100644 index 000000000..8b9a1430a --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs @@ -0,0 +1,18 @@ +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public record PackageExtension +{ + public required string Author { get; init; } + + public required string Title { get; init; } + + public required Uri Reference { get; init; } + + public required IEnumerable Files { get; init; } + + public string? Description { get; init; } + + public string? InstallType { get; init; } + + public bool IsInstalled { get; init; } +} diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtensionVersion.cs b/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtensionVersion.cs new file mode 100644 index 000000000..2a0e07e47 --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtensionVersion.cs @@ -0,0 +1,6 @@ +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public record PackageExtensionVersion : GitVersion +{ + public override string ToString() => base.ToString(); +}; diff --git a/StabilityMatrix.Core/Models/Packages/Fooocus.cs b/StabilityMatrix.Core/Models/Packages/Fooocus.cs index 698c9f1a2..7a4540d56 100644 --- a/StabilityMatrix.Core/Models/Packages/Fooocus.cs +++ b/StabilityMatrix.Core/Models/Packages/Fooocus.cs @@ -1,5 +1,7 @@ -using System.Collections.Immutable; -using System.Diagnostics; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; @@ -32,7 +34,9 @@ IPrerequisiteHelper prerequisiteHelper public override string LaunchCommand => "launch.py"; public override Uri PreviewImageUri => - new("https://user-images.githubusercontent.com/19834515/261830306-f79c5981-cf80-4ee3-b06b-3fef3f8bfbc7.png"); + new( + "https://user-images.githubusercontent.com/19834515/261830306-f79c5981-cf80-4ee3-b06b-3fef3f8bfbc7.png" + ); public override List LaunchOptions => new() @@ -71,6 +75,43 @@ IPrerequisiteHelper prerequisiteHelper Description = "Override the output directory", Options = { "--output-directory" } }, + new LaunchOptionDefinition + { + Name = "Language", + Type = LaunchOptionType.String, + Description = "Change the language of the UI", + Options = { "--language" } + }, + new LaunchOptionDefinition + { + Name = "Auto-Launch", + Type = LaunchOptionType.Bool, + Options = { "--auto-launch" } + }, + new LaunchOptionDefinition + { + Name = "Disable Image Log", + Type = LaunchOptionType.Bool, + Options = { "--disable-image-log" } + }, + new LaunchOptionDefinition + { + Name = "Disable Analytics", + Type = LaunchOptionType.Bool, + Options = { "--disable-analytics" } + }, + new LaunchOptionDefinition + { + Name = "Disable Preset Model Downloads", + Type = LaunchOptionType.Bool, + Options = { "--disable-preset-download" } + }, + new LaunchOptionDefinition + { + Name = "Always Download Newer Models", + Type = LaunchOptionType.Bool, + Options = { "--always-download-new-model" } + }, new() { Name = "VRAM", @@ -81,7 +122,13 @@ IPrerequisiteHelper prerequisiteHelper MemoryLevel.Medium => "--always-normal-vram", _ => null }, - Options = { "--always-high-vram", "--always-normal-vram", "--always-low-vram", "--always-no-vram" } + Options = + { + "--always-high-vram", + "--always-normal-vram", + "--always-low-vram", + "--always-no-vram" + } }, new LaunchOptionDefinition { @@ -98,19 +145,13 @@ IPrerequisiteHelper prerequisiteHelper InitialValue = !HardwareHelper.HasNvidiaGpu(), Options = { "--disable-xformers" } }, - new LaunchOptionDefinition - { - Name = "Auto-Launch", - Type = LaunchOptionType.Bool, - Options = { "--auto-launch" } - }, LaunchOptionDefinition.Extras }; - public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink; + public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Configuration; public override IEnumerable AvailableSharedFolderMethods => - new[] { SharedFolderMethod.Symlink, SharedFolderMethod.None }; + new[] { SharedFolderMethod.Symlink, SharedFolderMethod.Configuration, SharedFolderMethod.None }; public override Dictionary> SharedFolders => new() @@ -222,4 +263,111 @@ void HandleExit(int i) VenvRunner?.RunDetached(args.TrimEnd(), HandleConsoleOutput, HandleExit); } + + public override Task SetupModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) + { + return sharedFolderMethod switch + { + 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 + ) + { + return sharedFolderMethod switch + { + SharedFolderMethod.Symlink => base.RemoveModelFolderLinks(installDirectory, sharedFolderMethod), + SharedFolderMethod.Configuration => WriteDefaultConfig(installDirectory), + SharedFolderMethod.None => Task.CompletedTask, + _ => throw new ArgumentOutOfRangeException(nameof(sharedFolderMethod), sharedFolderMethod, null) + }; + } + + private JsonSerializerOptions jsonSerializerOptions = + new() { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; + + private async Task SetupModelFoldersConfig(DirectoryPath installDirectory) + { + var fooocusConfigPath = installDirectory.JoinFile("config.txt"); + + var fooocusConfig = new JsonObject(); + + if (fooocusConfigPath.Exists) + { + fooocusConfig = + JsonSerializer.Deserialize( + await fooocusConfigPath.ReadAllTextAsync().ConfigureAwait(false) + ) ?? new JsonObject(); + } + + fooocusConfig["path_checkpoints"] = Path.Combine(settingsManager.ModelsDirectory, "StableDiffusion"); + fooocusConfig["path_loras"] = Path.Combine(settingsManager.ModelsDirectory, "Lora"); + fooocusConfig["path_embeddings"] = Path.Combine(settingsManager.ModelsDirectory, "TextualInversion"); + fooocusConfig["path_vae_approx"] = Path.Combine(settingsManager.ModelsDirectory, "ApproxVAE"); + fooocusConfig["path_upscale_models"] = Path.Combine(settingsManager.ModelsDirectory, "ESRGAN"); + fooocusConfig["path_inpaint"] = Path.Combine(installDirectory, "models", "inpaint"); + fooocusConfig["path_controlnet"] = Path.Combine(settingsManager.ModelsDirectory, "ControlNet"); + fooocusConfig["path_clip_vision"] = Path.Combine(settingsManager.ModelsDirectory, "CLIP"); + fooocusConfig["path_fooocus_expansion"] = Path.Combine( + installDirectory, + "models", + "prompt_expansion", + "fooocus_expansion" + ); + + var outputsPath = Path.Combine(installDirectory, OutputFolderName); + + // doesn't always exist on first install + Directory.CreateDirectory(outputsPath); + fooocusConfig["path_outputs"] = outputsPath; + + await fooocusConfigPath + .WriteAllTextAsync(JsonSerializer.Serialize(fooocusConfig, jsonSerializerOptions)) + .ConfigureAwait(false); + } + + private async Task WriteDefaultConfig(DirectoryPath installDirectory) + { + var fooocusConfigPath = installDirectory.JoinFile("config.txt"); + + var fooocusConfig = new JsonObject(); + + if (fooocusConfigPath.Exists) + { + fooocusConfig = + JsonSerializer.Deserialize( + await fooocusConfigPath.ReadAllTextAsync().ConfigureAwait(false) + ) ?? new JsonObject(); + } + + fooocusConfig["path_checkpoints"] = Path.Combine(installDirectory, "models", "checkpoints"); + fooocusConfig["path_loras"] = Path.Combine(installDirectory, "models", "loras"); + fooocusConfig["path_embeddings"] = Path.Combine(installDirectory, "models", "embeddings"); + fooocusConfig["path_vae_approx"] = Path.Combine(installDirectory, "models", "vae_approx"); + fooocusConfig["path_upscale_models"] = Path.Combine(installDirectory, "models", "upscale_models"); + fooocusConfig["path_inpaint"] = Path.Combine(installDirectory, "models", "inpaint"); + fooocusConfig["path_controlnet"] = Path.Combine(installDirectory, "models", "controlnet"); + fooocusConfig["path_clip_vision"] = Path.Combine(installDirectory, "models", "clip_vision"); + fooocusConfig["path_fooocus_expansion"] = Path.Combine( + installDirectory, + "models", + "prompt_expansion", + "fooocus_expansion" + ); + fooocusConfig["path_outputs"] = Path.Combine(installDirectory, OutputFolderName); + + await fooocusConfigPath + .WriteAllTextAsync(JsonSerializer.Serialize(fooocusConfig, jsonSerializerOptions)) + .ConfigureAwait(false); + } } diff --git a/StabilityMatrix.Core/Models/Packages/FooocusMre.cs b/StabilityMatrix.Core/Models/Packages/FooocusMre.cs index d5d3516af..82497f0f5 100644 --- a/StabilityMatrix.Core/Models/Packages/FooocusMre.cs +++ b/StabilityMatrix.Core/Models/Packages/FooocusMre.cs @@ -28,16 +28,20 @@ IPrerequisiteHelper prerequisiteHelper public override string LicenseType => "GPL-3.0"; - public override string LicenseUrl => "https://github.com/MoonRide303/Fooocus-MRE/blob/moonride-main/LICENSE"; + public override string LicenseUrl => + "https://github.com/MoonRide303/Fooocus-MRE/blob/moonride-main/LICENSE"; public override string LaunchCommand => "launch.py"; public override Uri PreviewImageUri => - new("https://user-images.githubusercontent.com/130458190/265366059-ce430ea0-0995-4067-98dd-cef1d7dc1ab6.png"); + new( + "https://user-images.githubusercontent.com/130458190/265366059-ce430ea0-0995-4067-98dd-cef1d7dc1ab6.png" + ); public override string Disclaimer => "This package may no longer receive updates from its author. It may be removed from Stability Matrix in the future."; public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Impossible; + public override bool OfferInOneClickInstaller => false; public override List LaunchOptions => new() diff --git a/StabilityMatrix.Core/Models/Packages/InvokeAI.cs b/StabilityMatrix.Core/Models/Packages/InvokeAI.cs index 6118149b9..4cf84a9b3 100644 --- a/StabilityMatrix.Core/Models/Packages/InvokeAI.cs +++ b/StabilityMatrix.Core/Models/Packages/InvokeAI.cs @@ -160,6 +160,14 @@ public override TorchVersion GetRecommendedTorchVersion() return base.GetRecommendedTorchVersion(); } + public override IEnumerable Prerequisites => + [ + PackagePrerequisite.Python310, + PackagePrerequisite.VcRedist, + PackagePrerequisite.Git, + PackagePrerequisite.Node + ]; + public override async Task InstallPackage( string installLocation, TorchVersion torchVersion, @@ -283,11 +291,15 @@ private async Task SetupAndBuildInvokeFrontend( ) { await PrerequisiteHelper.InstallNodeIfNecessary(progress).ConfigureAwait(false); - await PrerequisiteHelper.RunNpm(["i", "pnpm"], installLocation).ConfigureAwait(false); + await PrerequisiteHelper + .RunNpm(["i", "pnpm"], installLocation, envVars: envVars) + .ConfigureAwait(false); if (Compat.IsMacOS || Compat.IsLinux) { - await PrerequisiteHelper.RunNpm(["i", "vite"], installLocation).ConfigureAwait(false); + await PrerequisiteHelper + .RunNpm(["i", "vite"], installLocation, envVars: envVars) + .ConfigureAwait(false); } var pnpmPath = Path.Combine( diff --git a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs index fa3adb34f..c38c0e1f2 100644 --- a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs +++ b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs @@ -32,7 +32,7 @@ IPyRunner runner 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 @@ IPyRunner runner 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 @@ def rewrite_module(self, module_text: str) -> str: """ ); - 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 @@ void HandleConsoleOutput(ProcessOutput s) var args = $"\"{Path.Combine(installedPackagePath, command)}\" {arguments}"; - VenvRunner.EnvironmentVariables = GetEnvVars(installedPackagePath); VenvRunner.RunDetached(args.TrimEnd(), HandleConsoleOutput, OnExit); } @@ -246,23 +249,4 @@ void HandleConsoleOutput(ProcessOutput s) 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 000000000..d113cb2dc --- /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 7eeae2f2c..c35b37b88 100644 --- a/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs +++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs @@ -31,7 +31,6 @@ IPrerequisiteHelper prerequisiteHelper 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 override TorchVersion GetRecommendedTorchVersion() => 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 override List LaunchOptions } } + 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 1f646d067..1b161ac90 100644 --- a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs +++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs @@ -28,7 +28,8 @@ IPrerequisiteHelper prerequisiteHelper 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 @@ IPrerequisiteHelper prerequisiteHelper public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink; - public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Simple; + public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Advanced; public override Dictionary> SharedFolders => new() @@ -141,7 +142,8 @@ IPrerequisiteHelper prerequisiteHelper Name = "No Half", Type = LaunchOptionType.Bool, Description = "Do not switch the model to 16-bit floats", - InitialValue = HardwareHelper.PreferRocm() || HardwareHelper.PreferDirectML(), + InitialValue = + HardwareHelper.PreferRocm() || HardwareHelper.PreferDirectML() || Compat.IsMacOS, Options = ["--no-half"] }, new() @@ -164,7 +166,7 @@ IPrerequisiteHelper prerequisiteHelper new[] { SharedFolderMethod.Symlink, SharedFolderMethod.None }; public override IEnumerable AvailableTorchVersions => - new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.Rocm }; + new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.Rocm, TorchVersion.Mps }; public override string MainBranch => "master"; @@ -198,6 +200,17 @@ public override async Task InstallPackage( case TorchVersion.Rocm: await InstallRocmTorch(venvRunner, progress, onConsoleOutput).ConfigureAwait(false); break; + case TorchVersion.Mps: + await venvRunner + .PipInstall( + new PipInstallArgs() + .WithTorch("==2.1.2") + .WithTorchVision() + .WithTorchExtraIndex("nightly/cpu"), + onConsoleOutput + ) + .ConfigureAwait(false); + break; } // Install requirements file diff --git a/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs b/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs index 383ace118..6aa2c0dc2 100644 --- a/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs +++ b/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs @@ -204,7 +204,9 @@ await venvRunner break; default: // CPU - await venvRunner.CustomInstall("launch.py --debug --test", onConsoleOutput).ConfigureAwait(false); + await venvRunner + .CustomInstall("launch.py --debug --test", onConsoleOutput) + .ConfigureAwait(false); break; } @@ -304,17 +306,14 @@ public override async Task Update( Action? onConsoleOutput = null ) { - progress?.Report( - new ProgressReport( - -1f, - message: "Downloading package update...", - isIndeterminate: true, - type: ProgressType.Update - ) - ); - - await PrerequisiteHelper - .RunGit(new[] { "checkout", versionOptions.BranchName! }, onConsoleOutput, installedPackage.FullPath) + var baseUpdateResult = await base.Update( + installedPackage, + torchVersion, + versionOptions, + progress, + includePrerelease, + onConsoleOutput + ) .ConfigureAwait(false); var venvRunner = new PyVenvRunner(Path.Combine(installedPackage.FullPath!, "venv")); @@ -325,14 +324,17 @@ await PrerequisiteHelper try { - var output = await PrerequisiteHelper - .GetGitOutput(installedPackage.FullPath, "rev-parse", "HEAD") + var result = await PrerequisiteHelper + .GetGitOutput(["rev-parse", "HEAD"], installedPackage.FullPath) + .EnsureSuccessExitCode() .ConfigureAwait(false); return new InstalledPackageVersion { InstalledBranch = versionOptions.BranchName, - InstalledCommitSha = output.Replace(Environment.NewLine, "").Replace("\n", ""), + InstalledCommitSha = result + .StandardOutput?.Replace(Environment.NewLine, "") + .Replace("\n", ""), IsPrerelease = false }; } @@ -343,18 +345,22 @@ await PrerequisiteHelper finally { progress?.Report( - new ProgressReport(1f, message: "Update Complete", isIndeterminate: false, type: ProgressType.Update) + new ProgressReport( + 1f, + message: "Update Complete", + isIndeterminate: false, + type: ProgressType.Update + ) ); } - return new InstalledPackageVersion - { - InstalledBranch = installedPackage.Version.InstalledBranch, - IsPrerelease = false - }; + return baseUpdateResult; } - public override Task SetupModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod) + public override Task SetupModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) { switch (sharedFolderMethod) { @@ -404,13 +410,19 @@ public override Task SetupModelFolders(DirectoryPath installDirectory, SharedFol configRoot["clip_models_path"] = Path.Combine(SettingsManager.ModelsDirectory, "CLIP"); configRoot["control_net_models_path"] = Path.Combine(SettingsManager.ModelsDirectory, "ControlNet"); - var configJsonStr = JsonSerializer.Serialize(configRoot, new JsonSerializerOptions { WriteIndented = true }); + var configJsonStr = JsonSerializer.Serialize( + configRoot, + new JsonSerializerOptions { WriteIndented = true } + ); File.WriteAllText(configJsonPath, configJsonStr); return Task.CompletedTask; } - public override Task UpdateModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod) => + public override Task UpdateModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) => sharedFolderMethod switch { SharedFolderMethod.Symlink => base.UpdateModelFolders(installDirectory, sharedFolderMethod), @@ -476,7 +488,10 @@ private Task RemoveConfigSettings(string installDirectory) configRoot.Remove("clip_models_path"); configRoot.Remove("control_net_models_path"); - var configJsonStr = JsonSerializer.Serialize(configRoot, new JsonSerializerOptions { WriteIndented = true }); + var configJsonStr = JsonSerializer.Serialize( + configRoot, + new JsonSerializerOptions { WriteIndented = true } + ); File.WriteAllText(configJsonPath, configJsonStr); return Task.CompletedTask; diff --git a/StabilityMatrix.Core/Models/Settings/NotificationKey.cs b/StabilityMatrix.Core/Models/Settings/NotificationKey.cs new file mode 100644 index 000000000..ee48b8a24 --- /dev/null +++ b/StabilityMatrix.Core/Models/Settings/NotificationKey.cs @@ -0,0 +1,88 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using StabilityMatrix.Core.Converters.Json; + +namespace StabilityMatrix.Core.Models.Settings; + +/// +/// Notification Names +/// +[SuppressMessage("ReSharper", "InconsistentNaming")] +[JsonConverter(typeof(ParsableStringValueJsonConverter))] +public record NotificationKey(string Value) : StringValue(Value), IParsable +{ + public NotificationOption DefaultOption { get; init; } + + public NotificationLevel Level { get; init; } + + public string? DisplayName { get; init; } + + public static NotificationKey Inference_PromptCompleted => + new("Inference_PromptCompleted") + { + DefaultOption = NotificationOption.NativePush, + Level = NotificationLevel.Success, + DisplayName = "Inference Prompt Completed" + }; + + public static NotificationKey Download_Completed => + new("Download_Completed") + { + DefaultOption = NotificationOption.NativePush, + Level = NotificationLevel.Success, + DisplayName = "Download Completed" + }; + + public static NotificationKey Download_Failed => + new("Download_Failed") + { + DefaultOption = NotificationOption.NativePush, + Level = NotificationLevel.Error, + DisplayName = "Download Failed" + }; + + public static NotificationKey Download_Canceled => + new("Download_Canceled") + { + DefaultOption = NotificationOption.NativePush, + Level = NotificationLevel.Warning, + DisplayName = "Download Canceled" + }; + + public static NotificationKey Package_Install_Completed => + new("Package_Install_Completed") + { + DefaultOption = NotificationOption.NativePush, + Level = NotificationLevel.Success, + DisplayName = "Package Install Completed" + }; + + public static NotificationKey Package_Install_Failed => + new("Package_Install_Failed") + { + DefaultOption = NotificationOption.NativePush, + Level = NotificationLevel.Error, + DisplayName = "Package Install Failed" + }; + + public static Dictionary All { get; } = GetValues(); + + /// + public override string ToString() => base.ToString(); + + /// + public static NotificationKey Parse(string s, IFormatProvider? provider) + { + return All[s]; + } + + /// + public static bool TryParse( + string? s, + IFormatProvider? provider, + [MaybeNullWhen(false)] out NotificationKey result + ) + { + return All.TryGetValue(s ?? "", out result); + } +} diff --git a/StabilityMatrix.Core/Models/Settings/NotificationLevel.cs b/StabilityMatrix.Core/Models/Settings/NotificationLevel.cs new file mode 100644 index 000000000..6300508b8 --- /dev/null +++ b/StabilityMatrix.Core/Models/Settings/NotificationLevel.cs @@ -0,0 +1,9 @@ +namespace StabilityMatrix.Core.Models.Settings; + +public enum NotificationLevel +{ + Information, + Success, + Warning, + Error +} diff --git a/StabilityMatrix.Core/Models/Settings/NotificationOption.cs b/StabilityMatrix.Core/Models/Settings/NotificationOption.cs new file mode 100644 index 000000000..261f3d10d --- /dev/null +++ b/StabilityMatrix.Core/Models/Settings/NotificationOption.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace StabilityMatrix.Core.Models.Settings; + +public enum NotificationOption +{ + [Display(Name = "None", Description = "No notification")] + None, + + [Display(Name = "In-App", Description = "Show a toast in the app")] + AppToast, + + [Display(Name = "Desktop", Description = "Native desktop push notification")] + NativePush +} diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index d9b8d7c05..50f34f119 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -2,6 +2,8 @@ using System.Text.Json.Serialization; using Semver; using StabilityMatrix.Core.Converters.Json; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Update; namespace StabilityMatrix.Core.Models.Settings; @@ -60,7 +62,7 @@ public InstalledPackage? ActiveInstalledPackage public bool ShowConnectedModelImages { get; set; } [JsonConverter(typeof(JsonStringEnumConverter))] - public SharedFolderType? SharedFolderVisibleCategories { get; set; } = + public SharedFolderType SharedFolderVisibleCategories { get; set; } = SharedFolderType.StableDiffusion | SharedFolderType.Lora | SharedFolderType.LyCORIS; public WindowSettings? WindowSettings { get; set; } @@ -107,6 +109,14 @@ public InstalledPackage? ActiveInstalledPackage public HashSet SeenTeachingTips { get; set; } = new(); + public Dictionary NotificationOptions { get; set; } = new(); + + public List SelectedBaseModels { get; set; } = + Enum.GetValues() + .Where(x => x != CivitBaseModelType.All) + .Select(x => x.GetStringValue()) + .ToList(); + public Size InferenceImageSize { get; set; } = new(150, 190); public Size OutputsImageSize { get; set; } = new(300, 300); public HolidayMode HolidayModeSetting { get; set; } = HolidayMode.Automatic; @@ -170,7 +180,10 @@ public static CultureInfo GetDefaultCulture() } } -[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] [JsonSerializable(typeof(Settings))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(int))] diff --git a/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs b/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs index 06053af7b..759714f93 100644 --- a/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs +++ b/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs @@ -6,19 +6,12 @@ namespace StabilityMatrix.Core.Models.Settings; /// /// Transaction object which saves settings manager changes when disposed. /// -public class SettingsTransaction : IDisposable, IAsyncDisposable +public class SettingsTransaction(ISettingsManager settingsManager, Func onCommit) + : IDisposable, + IAsyncDisposable { - private readonly ISettingsManager settingsManager; - private readonly Func onCommit; - public Settings Settings => settingsManager.Settings; - - public SettingsTransaction(ISettingsManager settingsManager, Func onCommit) - { - this.settingsManager = settingsManager; - this.onCommit = onCommit; - } - + public void Dispose() { onCommit().SafeFireAndForget(); @@ -27,7 +20,7 @@ public void Dispose() public async ValueTask DisposeAsync() { - await onCommit(); + await onCommit().ConfigureAwait(false); GC.SuppressFinalize(this); } } diff --git a/StabilityMatrix.Core/Models/Settings/TeachingTip.cs b/StabilityMatrix.Core/Models/Settings/TeachingTip.cs index 04dcd7202..756e6cce2 100644 --- a/StabilityMatrix.Core/Models/Settings/TeachingTip.cs +++ b/StabilityMatrix.Core/Models/Settings/TeachingTip.cs @@ -9,9 +9,10 @@ namespace StabilityMatrix.Core.Models.Settings; [JsonConverter(typeof(StringJsonConverter))] public record TeachingTip(string Value) : StringValue(Value) { - public static TeachingTip AccountsCredentialsStorageNotice => - new("AccountsCredentialsStorageNotice"); + public static TeachingTip AccountsCredentialsStorageNotice => new("AccountsCredentialsStorageNotice"); public static TeachingTip CheckpointCategoriesTip => new("CheckpointCategoriesTip"); + public static TeachingTip PackageExtensionsInstallNotice => new("PackageExtensionsInstallNotice"); + public static TeachingTip DownloadsTip => new("DownloadsTip"); /// public override string ToString() diff --git a/StabilityMatrix.Core/Models/StringValue.cs b/StabilityMatrix.Core/Models/StringValue.cs index 98e7ab485..e8d71898f 100644 --- a/StabilityMatrix.Core/Models/StringValue.cs +++ b/StabilityMatrix.Core/Models/StringValue.cs @@ -1,4 +1,8 @@ -namespace StabilityMatrix.Core.Models; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.Serialization; + +namespace StabilityMatrix.Core.Models; public abstract record StringValue(string Value) : IFormattable { @@ -13,4 +17,30 @@ public string ToString(string? format, IFormatProvider? formatProvider) { return Value; } + + /// + /// Get all values of type as a dictionary. + /// Includes all public static properties. + /// + protected static Dictionary GetValues< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T + >() + where T : StringValue + { + var values = new Dictionary(); + + foreach (var field in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (field.GetValue(null) is T value) + { + // Exclude if IgnoreDataMember + if (field.GetCustomAttribute() is not null) + continue; + + values.Add(value.Value, value); + } + } + + return values; + } } diff --git a/StabilityMatrix.Core/Models/TrackedDownload.cs b/StabilityMatrix.Core/Models/TrackedDownload.cs index e4017d524..fcf5f2541 100644 --- a/StabilityMatrix.Core/Models/TrackedDownload.cs +++ b/StabilityMatrix.Core/Models/TrackedDownload.cs @@ -71,42 +71,33 @@ public class TrackedDownload private int attempts; #region Events - private WeakEventManager? progressUpdateEventManager; + public event EventHandler? ProgressUpdate; - public event EventHandler ProgressUpdate - { - add - { - progressUpdateEventManager ??= new WeakEventManager(); - progressUpdateEventManager.AddEventHandler(value); - } - remove => progressUpdateEventManager?.RemoveEventHandler(value); - } - - protected void OnProgressUpdate(ProgressReport e) + private void OnProgressUpdate(ProgressReport e) { // Update downloaded and total bytes DownloadedBytes = Convert.ToInt64(e.Current); TotalBytes = Convert.ToInt64(e.Total); - progressUpdateEventManager?.RaiseEvent(this, e, nameof(ProgressUpdate)); + ProgressUpdate?.Invoke(this, e); } - private WeakEventManager? progressStateChangedEventManager; + public event EventHandler? ProgressStateChanging; - public event EventHandler ProgressStateChanged + private void OnProgressStateChanging(ProgressState e) { - add - { - progressStateChangedEventManager ??= new WeakEventManager(); - progressStateChangedEventManager.AddEventHandler(value); - } - remove => progressStateChangedEventManager?.RemoveEventHandler(value); + Logger.Debug("Download {Download}: State changing to {State}", FileName, e); + + ProgressStateChanging?.Invoke(this, e); } - protected void OnProgressStateChanged(ProgressState e) + public event EventHandler? ProgressStateChanged; + + private void OnProgressStateChanged(ProgressState e) { - progressStateChangedEventManager?.RaiseEvent(this, e, nameof(ProgressStateChanged)); + Logger.Debug("Download {Download}: State changed to {State}", FileName, e); + + ProgressStateChanged?.Invoke(this, e); } #endregion @@ -134,9 +125,7 @@ private async Task StartDownloadTask(long resumeFromByte, CancellationToken canc // If hash validation is enabled, validate the hash if (ValidateHash) { - OnProgressUpdate( - new ProgressReport(0, isIndeterminate: true, type: ProgressType.Hashing) - ); + OnProgressUpdate(new ProgressReport(0, isIndeterminate: true, type: ProgressType.Hashing)); var hash = await FileHash .GetSha256Async(DownloadDirectory.JoinFile(TempFileName), progress) .ConfigureAwait(false); @@ -167,6 +156,7 @@ public void Start() downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token) .ContinueWith(OnDownloadTaskCompleted); + OnProgressStateChanging(ProgressState.Working); ProgressState = ProgressState.Working; OnProgressStateChanged(ProgressState); } @@ -200,6 +190,7 @@ public void Resume() downloadTask = StartDownloadTask(tempSize, AggregateCancellationTokenSource.Token) .ContinueWith(OnDownloadTaskCompleted); + OnProgressStateChanging(ProgressState.Working); ProgressState = ProgressState.Working; OnProgressStateChanged(ProgressState); } @@ -244,6 +235,7 @@ public void Cancel() { DoCleanup(); + OnProgressStateChanging(ProgressState.Cancelled); ProgressState = ProgressState.Cancelled; OnProgressStateChanged(ProgressState); } @@ -287,11 +279,13 @@ private void OnDownloadTaskCompleted(Task task) // If the task was cancelled, set the state to cancelled if (downloadCancellationTokenSource?.IsCancellationRequested == true) { + OnProgressStateChanging(ProgressState.Cancelled); ProgressState = ProgressState.Cancelled; } // If the task was not cancelled, set the state to paused else if (downloadPauseTokenSource?.IsCancellationRequested == true) { + OnProgressStateChanging(ProgressState.Inactive); ProgressState = ProgressState.Inactive; } else @@ -307,10 +301,7 @@ private void OnDownloadTaskCompleted(Task task) // Set the exception Exception = task.Exception; - if ( - (Exception is IOException || Exception?.InnerException is IOException) - && attempts < 3 - ) + if ((Exception is IOException || Exception?.InnerException is IOException) && attempts < 3) { attempts++; Logger.Warn( @@ -319,16 +310,20 @@ private void OnDownloadTaskCompleted(Task task) Exception, attempts ); + + OnProgressStateChanging(ProgressState.Inactive); ProgressState = ProgressState.Inactive; Resume(); return; } + OnProgressStateChanging(ProgressState.Failed); ProgressState = ProgressState.Failed; } // Otherwise success else { + OnProgressStateChanging(ProgressState.Success); ProgressState = ProgressState.Success; } diff --git a/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs index b6711cd55..c8ea41eb1 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 9adac6d6d..a4122897a 100644 --- a/StabilityMatrix.Core/Processes/ProcessArgs.cs +++ b/StabilityMatrix.Core/Processes/ProcessArgs.cs @@ -21,8 +21,7 @@ public ProcessArgs(OneOf input) /// Whether the argument string contains the given substring, /// or any of the given arguments if the input is an array. /// - 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( @@ -55,10 +54,7 @@ IEnumerator IEnumerable.GetEnumerator() } public string[] ToArray() => - 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 diff --git a/StabilityMatrix.Core/Processes/ProcessResult.cs b/StabilityMatrix.Core/Processes/ProcessResult.cs index 9fca61713..509f7ab1e 100644 --- a/StabilityMatrix.Core/Processes/ProcessResult.cs +++ b/StabilityMatrix.Core/Processes/ProcessResult.cs @@ -22,3 +22,13 @@ public void EnsureSuccessExitCode() } } } + +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 f29fc72c2..2da50556b 100644 --- a/StabilityMatrix.Core/Processes/ProcessRunner.cs +++ b/StabilityMatrix.Core/Processes/ProcessRunner.cs @@ -29,6 +29,28 @@ public static void OpenUrl(Uri url) OpenUrl(url.AbsoluteUri); } + /// + /// Start an executable or .app on macOS. + /// + public static Process StartApp(string path, ProcessArgs args) + { + var startInfo = new ProcessStartInfo(); + + if (Compat.IsMacOS) + { + startInfo.FileName = "open"; + startInfo.Arguments = args.Prepend(path).ToString(); + startInfo.UseShellExecute = true; + } + else + { + startInfo.FileName = path; + startInfo.Arguments = args; + } + + return Process.Start(startInfo) ?? throw new NullReferenceException("Process.Start returned null"); + } + /// /// Opens the given folder in the system file explorer. /// @@ -92,7 +114,7 @@ public static async Task OpenFileBrowser(string filePath) 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,18 +353,13 @@ public static AnsiProcess StartAnsiProcess( { // 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 = "" + string workingDirectory = "", + IReadOnlyDictionary? environmentVariables = null ) { // Escape any single quotes in the command @@ -359,6 +376,14 @@ public static async Task RunBashCommand( WorkingDirectory = workingDirectory, }; + if (environmentVariables != null) + { + foreach (var (key, value) in environmentVariables) + { + processInfo.EnvironmentVariables[key] = value; + } + } + using var process = new Process(); process.StartInfo = processInfo; @@ -383,12 +408,13 @@ public static async Task RunBashCommand( public static Task RunBashCommand( IEnumerable commands, - string workingDirectory = "" + string workingDirectory = "", + IReadOnlyDictionary? environmentVariables = null ) { // Quote arguments containing spaces var args = string.Join(" ", commands.Select(Quote)); - return RunBashCommand(args, workingDirectory); + return RunBashCommand(args, workingDirectory, environmentVariables); } /// diff --git a/StabilityMatrix.Core/Python/PyRunner.cs b/StabilityMatrix.Core/Python/PyRunner.cs index b053a2813..6e384449c 100644 --- a/StabilityMatrix.Core/Python/PyRunner.cs +++ b/StabilityMatrix.Core/Python/PyRunner.cs @@ -12,13 +12,7 @@ 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 async Task Initialize() 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 async Task InstallPackage(string package) 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/Python/PyVenvRunner.cs b/StabilityMatrix.Core/Python/PyVenvRunner.cs index d954fe80a..2266498b4 100644 --- a/StabilityMatrix.Core/Python/PyVenvRunner.cs +++ b/StabilityMatrix.Core/Python/PyVenvRunner.cs @@ -515,7 +515,7 @@ public void RunDetached( "Launching venv process [{PythonPath}] " + "in working directory [{WorkingDirectory}] with args {Arguments}", PythonPath, - WorkingDirectory, + WorkingDirectory?.ToString(), arguments ); diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index ed4a5763f..60adf231f 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -43,7 +43,12 @@ public async Task DownloadToFileAsync( 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 async Task DownloadToFileAsync( } } - 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 async Task ResumeDownloadToFileAsync( 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 async Task ResumeDownloadToFileAsync( 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 async Task ResumeDownloadToFileAsync( 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 async Task GetFileSizeAsync( 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) @@ -287,6 +305,18 @@ public async Task GetFileSizeAsync( } } + public async Task GetContentAsync(string url, CancellationToken cancellationToken = default) + { + using var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); + + await AddConditionalHeaders(client, new Uri(url)).ConfigureAwait(false); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + /// /// Adds conditional headers to the HttpClient for the given URL /// @@ -303,7 +333,10 @@ private async Task AddConditionalHeaders(HttpClient client, Uri url) 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/IDownloadService.cs b/StabilityMatrix.Core/Services/IDownloadService.cs index 883b1542a..2223c25c6 100644 --- a/StabilityMatrix.Core/Services/IDownloadService.cs +++ b/StabilityMatrix.Core/Services/IDownloadService.cs @@ -28,4 +28,6 @@ Task GetFileSizeAsync( ); Task GetImageStreamFromUrl(string url); + + Task GetContentAsync(string url, CancellationToken cancellationToken = default); } diff --git a/StabilityMatrix.Core/Services/IModelIndexService.cs b/StabilityMatrix.Core/Services/IModelIndexService.cs index 83c7940b4..69013b5b8 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/ISettingsManager.cs b/StabilityMatrix.Core/Services/ISettingsManager.cs index 52e9c3e8e..a070fead2 100644 --- a/StabilityMatrix.Core/Services/ISettingsManager.cs +++ b/StabilityMatrix.Core/Services/ISettingsManager.cs @@ -9,20 +9,19 @@ namespace StabilityMatrix.Core.Services; public interface ISettingsManager { bool IsPortableMode { get; } - string? LibraryDirOverride { set; } - string LibraryDir { get; } + DirectoryPath LibraryDir { get; } bool IsLibraryDirSet { get; } - string DatabasePath { get; } + string ModelsDirectory { get; } string DownloadsDirectory { get; } DirectoryPath TagsDirectory { get; } DirectoryPath ImagesDirectory { get; } DirectoryPath ImagesInferenceDirectory { get; } - - List PackageInstallsInProgress { get; set; } + DirectoryPath ConsolidatedImagesDirectory { get; } Settings Settings { get; } - DirectoryPath ConsolidatedImagesDirectory { get; } + + List PackageInstallsInProgress { get; set; } /// /// Event fired when the library directory is changed @@ -35,15 +34,20 @@ public interface ISettingsManager event EventHandler? SettingsPropertyChanged; /// - /// Register a handler that fires once when LibraryDir is first set. - /// Will fire instantly if it is already set. + /// Event fired when Settings are loaded from disk /// - void RegisterOnLibraryDirSet(Action handler); + event EventHandler? Loaded; /// - /// Event fired when Settings are loaded from disk + /// Set an override for the library directory. /// - event EventHandler? Loaded; + void SetLibraryDirOverride(DirectoryPath path); + + /// + /// Register a handler that fires once when LibraryDir is first set. + /// Will fire instantly if it is already set. + /// + void RegisterOnLibraryDirSet(Action handler); /// /// Return a SettingsTransaction that can be used to modify Settings @@ -56,6 +60,7 @@ public interface ISettingsManager /// Commits changes after the function returns. /// /// Function accepting Settings to modify + /// Ignore missing library dir when committing changes void Transaction(Action func, bool ignoreMissingLibraryDir = false); /// @@ -102,29 +107,7 @@ Action onPropertyChanged /// void SetPortableMode(); - /// - /// Iterable of installed packages using the old absolute path format. - /// Can be called with Any() to check if the user needs to migrate. - /// - IEnumerable GetOldInstalledPackages(); - - Guid GetOldActivePackageId(); - void AddPathExtension(string pathExtension); - string GetPathExtensionsAsString(); - - /// - /// Insert path extensions to the front of the PATH environment variable - /// - void InsertPathExtensions(); - - void UpdatePackageVersionNumber(Guid id, InstalledPackageVersion? newVersion); - void SetLastUpdateCheck(InstalledPackage package); - List GetLaunchArgs(Guid packageId); - void SaveLaunchArgs(Guid packageId, List launchArgs); - string? GetActivePackageHost(); - string? GetActivePackagePort(); - void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible); - bool IsSharedFolderCategoryVisible(SharedFolderType type); + void SaveLaunchArgs(Guid packageId, IEnumerable launchArgs); bool IsEulaAccepted(); void SetEulaAccepted(); void IndexCheckpoints(); diff --git a/StabilityMatrix.Core/Services/ImageIndexService.cs b/StabilityMatrix.Core/Services/ImageIndexService.cs index 3b6764b3a..81748b76b 100644 --- a/StabilityMatrix.Core/Services/ImageIndexService.cs +++ b/StabilityMatrix.Core/Services/ImageIndexService.cs @@ -26,10 +26,7 @@ public ImageIndexService(ILogger logger, ISettingsManager set this.logger = logger; this.settingsManager = settingsManager; - InferenceImages = new IndexCollection( - this, - file => file.AbsolutePath - ) + InferenceImages = new IndexCollection(this, file => file.AbsolutePath) { RelativePath = "Inference" }; @@ -56,7 +53,7 @@ public async Task RefreshIndex(IndexCollection indexColl // Start var stopwatch = Stopwatch.StartNew(); - logger.LogInformation("Refreshing images index at {SearchDir}...", searchDir); + logger.LogInformation("Refreshing images index at {SearchDir}...", searchDir.ToString()); var toAdd = new ConcurrentBag(); @@ -64,9 +61,7 @@ await Task.Run(() => { var files = searchDir .EnumerateFiles("*.*", SearchOption.AllDirectories) - .Where( - file => LocalImageFile.SupportedImageExtensions.Contains(file.Extension) - ); + .Where(file => LocalImageFile.SupportedImageExtensions.Contains(file.Extension)); Parallel.ForEach( files, diff --git a/StabilityMatrix.Core/Services/MetadataImportService.cs b/StabilityMatrix.Core/Services/MetadataImportService.cs index 5a88fb832..d31a603d9 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; @@ -236,7 +237,7 @@ await updatedCmInfo new ProgressReport( current: report.Current ?? 0, total: report.Total ?? 0, - $"Getting metadata for {filePath} ... {report.Percentage}%" + $"Getting metadata for {fileNameWithoutExtension} ... {report.Percentage}%" ) ); }); diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs index 67a676697..164433543 100644 --- a/StabilityMatrix.Core/Services/ModelIndexService.cs +++ b/StabilityMatrix.Core/Services/ModelIndexService.cs @@ -1,57 +1,59 @@ using System.Diagnostics; using System.Text; using AsyncAwaitBestPractices; +using AutoCtor; using Microsoft.Extensions.Logging; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Database; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Database; 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; + private readonly ModelFinder modelFinder; 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 async Task RefreshIndex() var added = 0; var newIndex = new Dictionary>(); + foreach ( var file in modelsDir .Info.EnumerateFiles("*.*", SearchOption.AllDirectories) @@ -161,6 +164,7 @@ await jsonPath.ReadAllTextAsync().ConfigureAwait(false) // Add to index var list = newIndex.GetOrAdd(sharedFolderType); list.Add(localModel); + added++; } @@ -195,4 +199,63 @@ public void BackgroundRefreshIndex() 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; + } + + // idk do somethin with this + public async Task CheckModelsForUpdateAsync() + { + var installedHashes = settingsManager.Settings.InstalledModelHashes; + var dbModels = ( + await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) + ?? Enumerable.Empty() + ).ToList(); + var ids = dbModels + .Where(x => x.ConnectedModelInfo != null) + .Where( + x => x.LastUpdateCheck == default || x.LastUpdateCheck < DateTimeOffset.UtcNow.AddHours(-8) + ) + .Select(x => x.ConnectedModelInfo!.ModelId); + var remoteModels = (await modelFinder.FindRemoteModelsById(ids).ConfigureAwait(false)).ToList(); + + foreach (var dbModel in dbModels) + { + if (dbModel.ConnectedModelInfo == null) + continue; + + var remoteModel = remoteModels.FirstOrDefault(m => m.Id == dbModel.ConnectedModelInfo!.ModelId); + + var latestVersion = remoteModel?.ModelVersions?.FirstOrDefault(); + var latestHashes = latestVersion + ?.Files + ?.Where(f => f.Type == CivitFileType.Model) + .Select(f => f.Hashes.BLAKE3); + + if (latestHashes == null) + continue; + + dbModel.HasUpdate = !latestHashes.Any(hash => installedHashes?.Contains(hash) ?? false); + dbModel.LastUpdateCheck = DateTimeOffset.UtcNow; + + await liteDbContext.LocalModelFiles.UpsertAsync(dbModel).ConfigureAwait(false); + } + } } diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index dc1803673..115c8615b 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -1,9 +1,9 @@ using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; using System.Text.Json; -using System.Text.Json.Serialization; using AsyncAwaitBestPractices; using NLog; using StabilityMatrix.Core.Attributes; @@ -20,28 +20,25 @@ public class SettingsManager : ISettingsManager { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly ReaderWriterLockSlim FileLock = new(); - private bool isLoaded; - private static string GlobalSettingsPath => Path.Combine(Compat.AppDataHome, "global.json"); - public string? LibraryDirOverride { private get; set; } + private bool isLoaded; - private readonly string? originalEnvPath = Environment.GetEnvironmentVariable( - "PATH", - EnvironmentVariableTarget.Process - ); + private DirectoryPath? libraryDirOverride; // Library properties public bool IsPortableMode { get; private set; } - private string? libraryDir; - public string LibraryDir + + private DirectoryPath? libraryDir; + public DirectoryPath LibraryDir { get { - if (string.IsNullOrWhiteSpace(libraryDir)) + if (libraryDir is null) { throw new InvalidOperationException("LibraryDir is not set"); } + return libraryDir; } private set @@ -57,23 +54,23 @@ private set } } } - public bool IsLibraryDirSet => !string.IsNullOrWhiteSpace(libraryDir); + + [MemberNotNullWhen(true, nameof(libraryDir))] + public bool IsLibraryDirSet => libraryDir is not null; // Dynamic paths from library - public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db"); - private string SettingsPath => Path.Combine(LibraryDir, "settings.json"); + private FilePath SettingsFile => LibraryDir.JoinFile("settings.json"); public string ModelsDirectory => Path.Combine(LibraryDir, "Models"); public string DownloadsDirectory => Path.Combine(LibraryDir, ".downloads"); - public List PackageInstallsInProgress { get; set; } = new(); - - public DirectoryPath TagsDirectory => new(LibraryDir, "Tags"); - - public DirectoryPath ImagesDirectory => new(LibraryDir, "Images"); + public DirectoryPath TagsDirectory => LibraryDir.JoinDir("Tags"); + public DirectoryPath ImagesDirectory => LibraryDir.JoinDir("Images"); public DirectoryPath ImagesInferenceDirectory => ImagesDirectory.JoinDir("Inference"); public DirectoryPath ConsolidatedImagesDirectory => ImagesDirectory.JoinDir("Consolidated"); public Settings Settings { get; private set; } = new(); + public List PackageInstallsInProgress { get; set; } = []; + /// public event EventHandler? LibraryDirChanged; @@ -83,6 +80,12 @@ private set /// public event EventHandler? Loaded; + /// + public void SetLibraryDirOverride(DirectoryPath path) + { + libraryDirOverride = path; + } + /// public void RegisterOnLibraryDirSet(Action handler) { @@ -270,11 +273,11 @@ public bool TryFindLibrary(bool forceReload = false) return true; // 0. Check Override - if (!string.IsNullOrEmpty(LibraryDirOverride)) + if (libraryDirOverride is not null) { - var fullOverridePath = Path.GetFullPath(LibraryDirOverride); + var fullOverridePath = libraryDirOverride.Info.FullName; Logger.Info("Using library override path: {Path}", fullOverridePath); - LibraryDir = fullOverridePath; + LibraryDir = libraryDirOverride; SetStaticLibraryPaths(); LoadSettings(); return true; @@ -361,119 +364,7 @@ public void SetPortableMode() dataDir.JoinFile(".sm-portable").Create(); } - /// - /// Iterable of installed packages using the old absolute path format. - /// Can be called with Any() to check if the user needs to migrate. - /// - public IEnumerable GetOldInstalledPackages() - { - var oldSettingsPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "StabilityMatrix", - "settings.json" - ); - - if (!File.Exists(oldSettingsPath)) - yield break; - - var oldSettingsJson = File.ReadAllText(oldSettingsPath); - var oldSettings = JsonSerializer.Deserialize( - oldSettingsJson, - new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } } - ); - - // Absolute paths are old formats requiring migration -#pragma warning disable CS0618 - var oldPackages = oldSettings?.InstalledPackages.Where(package => package.Path != null); -#pragma warning restore CS0618 - - if (oldPackages == null) - yield break; - - foreach (var package in oldPackages) - { - yield return package; - } - } - - public Guid GetOldActivePackageId() - { - var oldSettingsPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "StabilityMatrix", - "settings.json" - ); - - if (!File.Exists(oldSettingsPath)) - return default; - - var oldSettingsJson = File.ReadAllText(oldSettingsPath); - var oldSettings = JsonSerializer.Deserialize( - oldSettingsJson, - new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } } - ); - - if (oldSettings == null) - return default; - - return oldSettings.ActiveInstalledPackageId ?? default; - } - - public void AddPathExtension(string pathExtension) - { - Settings.PathExtensions ??= new List(); - Settings.PathExtensions.Add(pathExtension); - SaveSettings(); - } - - public string GetPathExtensionsAsString() - { - return string.Join(";", Settings.PathExtensions ?? new List()); - } - - /// - /// Insert path extensions to the front of the PATH environment variable - /// - public void InsertPathExtensions() - { - if (Settings.PathExtensions == null) - return; - var toInsert = GetPathExtensionsAsString(); - // Append the original path, if any - if (originalEnvPath != null) - { - toInsert += $";{originalEnvPath}"; - } - Environment.SetEnvironmentVariable("PATH", toInsert, EnvironmentVariableTarget.Process); - } - - public void UpdatePackageVersionNumber(Guid id, InstalledPackageVersion? newVersion) - { - var package = Settings.InstalledPackages.FirstOrDefault(x => x.Id == id); - if (package == null || newVersion == null) - { - return; - } - - package.Version = newVersion; - SaveSettings(); - } - - public void SetLastUpdateCheck(InstalledPackage package) - { - var installedPackage = Settings.InstalledPackages.First(p => p.DisplayName == package.DisplayName); - installedPackage.LastUpdateCheck = package.LastUpdateCheck; - installedPackage.UpdateAvailable = package.UpdateAvailable; - SaveSettings(); - } - - public List GetLaunchArgs(Guid packageId) - { - var packageData = Settings.InstalledPackages.FirstOrDefault(x => x.Id == packageId); - return packageData?.LaunchArgs ?? new(); - } - - public void SaveLaunchArgs(Guid packageId, List launchArgs) + public void SaveLaunchArgs(Guid packageId, IEnumerable launchArgs) { var packageData = Settings.InstalledPackages.FirstOrDefault(x => x.Id == packageId); if (packageData == null) @@ -487,58 +378,6 @@ public void SaveLaunchArgs(Guid packageId, List launchArgs) SaveSettings(); } - public string? GetActivePackageHost() - { - var package = Settings.InstalledPackages.FirstOrDefault( - x => x.Id == Settings.ActiveInstalledPackageId - ); - if (package == null) - return null; - var hostOption = package.LaunchArgs?.FirstOrDefault(x => x.Name.ToLowerInvariant() == "host"); - if (hostOption?.OptionValue != null) - { - return hostOption.OptionValue as string; - } - return hostOption?.DefaultValue as string; - } - - public string? GetActivePackagePort() - { - var package = Settings.InstalledPackages.FirstOrDefault( - x => x.Id == Settings.ActiveInstalledPackageId - ); - if (package == null) - return null; - var portOption = package.LaunchArgs?.FirstOrDefault(x => x.Name.ToLowerInvariant() == "port"); - if (portOption?.OptionValue != null) - { - return portOption.OptionValue as string; - } - return portOption?.DefaultValue as string; - } - - public void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible) - { - Settings.SharedFolderVisibleCategories ??= new SharedFolderType(); - if (visible) - { - Settings.SharedFolderVisibleCategories |= type; - } - else - { - Settings.SharedFolderVisibleCategories &= ~type; - } - SaveSettings(); - } - - public bool IsSharedFolderCategoryVisible(SharedFolderType type) - { - // False for default - if (type == 0) - return false; - return Settings.SharedFolderVisibleCategories?.HasFlag(type) ?? false; - } - public bool IsEulaAccepted() { if (!File.Exists(GlobalSettingsPath)) @@ -607,22 +446,23 @@ protected virtual void LoadSettings() FileLock.EnterReadLock(); try { - var settingsFile = new FilePath(SettingsPath); - - if (!settingsFile.Exists) + if (!SettingsFile.Exists) { - settingsFile.Directory?.Create(); - settingsFile.Create(); + SettingsFile.Directory?.Create(); + SettingsFile.Create(); - var settingsJson = JsonSerializer.Serialize(Settings); - settingsFile.WriteAllText(settingsJson); + var settingsJson = JsonSerializer.Serialize( + Settings, + SettingsSerializerContext.Default.Settings + ); + SettingsFile.WriteAllText(settingsJson); Loaded?.Invoke(this, EventArgs.Empty); isLoaded = true; return; } - using var fileStream = settingsFile.Info.OpenRead(); + using var fileStream = SettingsFile.Info.OpenRead(); if (fileStream.Length == 0) { @@ -653,18 +493,17 @@ protected virtual void SaveSettings() FileLock.TryEnterWriteLock(TimeSpan.FromSeconds(30)); try { - var settingsFile = new FilePath(SettingsPath); + if (!isLoaded) + return; - if (!settingsFile.Exists) + // Create empty settings file if it doesn't exist + if (!SettingsFile.Exists) { - settingsFile.Directory?.Create(); - settingsFile.Create(); + SettingsFile.Directory?.Create(); + SettingsFile.Create(); } - if (!isLoaded) - return; - - if (SystemInfo.GetDiskFreeSpaceBytes(SettingsPath) is < 1 * SystemInfo.Mebibyte) + if (SystemInfo.GetDiskFreeSpaceBytes(SettingsFile) is < 1 * SystemInfo.Mebibyte) { Logger.Warn("Not enough disk space to save settings"); return; @@ -675,7 +514,20 @@ protected virtual void SaveSettings() 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(SettingsFile, 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/Services/TrackedDownloadService.cs b/StabilityMatrix.Core/Services/TrackedDownloadService.cs index 9a985a214..941c644f9 100644 --- a/StabilityMatrix.Core/Services/TrackedDownloadService.cs +++ b/StabilityMatrix.Core/Services/TrackedDownloadService.cs @@ -15,10 +15,8 @@ public class TrackedDownloadService : ITrackedDownloadService, IDisposable private readonly ISettingsManager settingsManager; private readonly IModelIndexService modelIndexService; - private readonly ConcurrentDictionary< - Guid, - (TrackedDownload Download, FileStream Stream) - > downloads = new(); + private readonly ConcurrentDictionary downloads = + new(); public IEnumerable Downloads => downloads.Values.Select(x => x.Download); @@ -51,6 +49,7 @@ ISettingsManager settingsManager private void OnDownloadAdded(TrackedDownload download) { + logger.LogInformation("Download added: ({Download}, {State})", download.Id, download.ProgressState); DownloadAdded?.Invoke(this, download); } @@ -67,11 +66,7 @@ private void AddDownload(TrackedDownload download) var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory); downloadsDir.Create(); var jsonFile = downloadsDir.JoinFile($"{download.Id}.json"); - var jsonFileStream = jsonFile.Info.Open( - FileMode.CreateNew, - FileAccess.ReadWrite, - FileShare.Read - ); + var jsonFileStream = jsonFile.Info.Open(FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read); // Serialize to json var json = JsonSerializer.Serialize(download); @@ -84,7 +79,6 @@ private void AddDownload(TrackedDownload download) // Connect to state changed event to update json file AttachHandlers(download); - logger.LogDebug("Added download {Download}", download.FileName); OnDownloadAdded(download); } diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 6697dbee3..152ab78f6 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -17,38 +17,44 @@ + - - - - - - - + + + + + + + + + + + - - + + - + - + - + - - + + + diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 7a7a12896..d14291824 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -10,6 +10,7 @@ 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; @@ -31,7 +32,10 @@ public class UpdateHelper : IUpdateHelper public const string UpdateFolderName = ".StabilityMatrixUpdate"; public static DirectoryPath UpdateFolder => Compat.AppCurrentDir.JoinDir(UpdateFolderName); - public static FilePath ExecutablePath => UpdateFolder.JoinFile(Compat.GetExecutableName()); + public static IPathObject ExecutablePath => + Compat.IsMacOS + ? UpdateFolder.JoinDir(Compat.GetAppName()) + : UpdateFolder.JoinFile(Compat.GetAppName()); /// public event EventHandler? UpdateStatusChanged; @@ -88,10 +92,7 @@ public async Task DownloadUpdate(UpdateInfo updateInfo, IProgress f.Extension.ToLowerInvariant() is ".exe" or ".appimage"); - await binaryFile.MoveToAsync(ExecutablePath).ConfigureAwait(false); + await binaryFile.MoveToAsync((FilePath)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 dir and move it up to the root + var appBundle = extractDir.EnumerateDirectories("*.app").First(); + + await appBundle.MoveToAsync((DirectoryPath)ExecutablePath).ConfigureAwait(false); } // Otherwise just rename else @@ -184,9 +200,7 @@ await response.Content.ReadAsStreamAsync().ConfigureAwait(false), 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 ) ) { @@ -200,10 +214,10 @@ var channel in Enum.GetValues(typeof(UpdateChannel)) new UpdateStatusChangedEventArgs { LatestUpdate = update, - UpdateChannels = updateManifest.Updates.ToDictionary( - kv => kv.Key, - kv => kv.Value.GetInfoForCurrentPlatform() - )! + UpdateChannels = updateManifest + .Updates.Select(kv => (kv.Key, kv.Value.GetInfoForCurrentPlatform())) + .Where(kv => kv.Item2 is not null) + .ToDictionary(kv => kv.Item1, kv => kv.Item2)! } ); return; @@ -212,15 +226,14 @@ var channel in Enum.GetValues(typeof(UpdateChannel)) logger.LogInformation("No update available"); - OnUpdateStatusChanged( - new UpdateStatusChangedEventArgs - { - UpdateChannels = updateManifest.Updates.ToDictionary( - kv => kv.Key, - kv => kv.Value.GetInfoForCurrentPlatform() - )! - } - ); + var args = new UpdateStatusChangedEventArgs + { + UpdateChannels = updateManifest + .Updates.Select(kv => (kv.Key, kv.Value.GetInfoForCurrentPlatform())) + .Where(kv => kv.Item2 is not null) + .ToDictionary(kv => kv.Item1, kv => kv.Item2)! + }; + OnUpdateStatusChanged(args); } catch (Exception e) { @@ -295,11 +308,7 @@ private void OnUpdateStatusChanged(UpdateStatusChangedEventArgs args) 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/Core/FileSystemPathTests.cs b/StabilityMatrix.Tests/Core/FileSystemPathTests.cs new file mode 100644 index 000000000..88425ad9a --- /dev/null +++ b/StabilityMatrix.Tests/Core/FileSystemPathTests.cs @@ -0,0 +1,91 @@ +using System.Runtime.Versioning; +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class FileSystemPathTests +{ + [SupportedOSPlatform("windows")] + [DataTestMethod] + [DataRow("M:\\Path", "M:\\Path")] + [DataRow("root/abc", "root/abc")] + [DataRow("root\\abc", "root\\abc")] + public void TestFilePathEqualsWin(string left, string right) + { + // Arrange + var leftPath = new FilePath(left); + var rightPath = new FilePath(right); + + // Act + var resultEquals = leftPath.Equals(rightPath); + var resultOperator = leftPath == rightPath; + var resultNotOperator = leftPath != rightPath; + + // Assert + Assert.IsTrue(resultEquals); + Assert.IsTrue(resultOperator); + Assert.IsFalse(resultNotOperator); + } + + [DataTestMethod] + [DataRow("M:/Path", "M:/Path")] + [DataRow("root/abc", "root/abc")] + [DataRow("root/abc", "root/abc")] + public void TestFilePathEquals(string left, string right) + { + // Arrange + var leftPath = new FilePath(left); + var rightPath = new FilePath(right); + + // Act + var resultEquals = leftPath.Equals(rightPath); + var resultOperator = leftPath == rightPath; + var resultNotOperator = leftPath != rightPath; + + // Assert + Assert.IsTrue(resultEquals); + Assert.IsTrue(resultOperator); + Assert.IsFalse(resultNotOperator); + } + + [DataTestMethod] + [DataRow("M:/Path", "M:/Path2")] + [DataRow("root/abc", "root/abc2")] + public void TestFilePathNotEquals(string left, string right) + { + // Arrange + var leftPath = new FilePath(left); + var rightPath = new FilePath(right); + + // Act + var resultEquals = leftPath.Equals(rightPath); + var resultOperator = leftPath == rightPath; + var resultNotOperator = leftPath != rightPath; + + // Assert + Assert.IsFalse(resultEquals); + Assert.IsFalse(resultOperator); + Assert.IsTrue(resultNotOperator); + } + + [DataTestMethod] + [DataRow("root/abc", "root/abc")] + [DataRow("root/abc", "root/abc/")] + public void TestDirectoryPathEquals(string left, string right) + { + // Arrange + var leftPath = new DirectoryPath(left); + var rightPath = new DirectoryPath(right); + + // Act + var resultEquals = leftPath.Equals(rightPath); + var resultOperator = leftPath == rightPath; + var resultNotOperator = leftPath != rightPath; + + // Assert + Assert.IsTrue(resultEquals); + Assert.IsTrue(resultOperator); + Assert.IsFalse(resultNotOperator); + } +} diff --git a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj index 874234751..ad5c3f672 100644 --- a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj +++ b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj @@ -1,7 +1,12 @@ - - + net8.0 + + + net8.0-windows10.0.17763.0 + + + win-x64;linux-x64;osx-x64;osx-arm64 enable enable @@ -11,9 +16,10 @@ - + + - + @@ -22,7 +28,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/StabilityMatrix.UITests/Extensions/WindowExtensions.cs b/StabilityMatrix.UITests/Extensions/WindowExtensions.cs index 922ea1470..f86ad7b9f 100644 --- a/StabilityMatrix.UITests/Extensions/WindowExtensions.cs +++ b/StabilityMatrix.UITests/Extensions/WindowExtensions.cs @@ -19,16 +19,12 @@ public static void ClickTarget(this TopLevel topLevel, Control target) } 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 async Task ClickTargetAsync(this TopLevel topLevel, Control target } 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 async Task ClickTargetAsync(this TopLevel topLevel, Control target // 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 cc2c3f13c..2e2037468 100644 --- a/StabilityMatrix.UITests/MainWindowTests.cs +++ b/StabilityMatrix.UITests/MainWindowTests.cs @@ -1,143 +1,28 @@ 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; -[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