diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88b5eae3..aeffd47d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,10 @@ on: - stable - preview - development + test-release-artifacts: + type: boolean + description: "[Debug] Test release artifacts?" + default: false jobs: release-linux: @@ -171,11 +175,122 @@ jobs: with: name: StabilityMatrix-${{ env.platform-id }} path: ./out/${{ env.out-name }} - + + release-macos: + name: Release (macos-arm64) + env: + platform-id: osx-arm64 + app-name: "Stability Matrix.app" + out-name: "StabilityMatrix-macos-arm64.dmg" + runs-on: macos-13 + steps: + - uses: actions/checkout@v3 + + - uses: olegtarasov/get-tag@v2.1.2 + if: github.event_name == 'release' + id: tag_name + with: + tagRegex: "v(.*)" + + - name: Set Version from Tag + if: github.event_name == 'release' + run: | + echo "Using tag ${{ env.GIT_TAG_NAME }}" + echo "RELEASE_VERSION=${{ env.GIT_TAG_NAME }}" >> $GITHUB_ENV + + - name: Set Version from manual input + if: github.event_name == 'workflow_dispatch' + run: | + echo "Using version ${{ github.event.inputs.version }}" + echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + + - name: Set up .NET 8 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.x' + + - name: Install dependencies + run: dotnet restore -p:PublishReadyToRun=true + + - name: Check Version + run: echo $RELEASE_VERSION + + - name: .NET Msbuild (App) + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: > + dotnet msbuild ./StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj + -t:BundleApp -p:UseAppHost=true -p:SelfContained=true + -p:Configuration=Release -p:RuntimeIdentifier=${{ env.platform-id }} + -p:Version=$RELEASE_VERSION + -p:PublishDir=out + -p:PublishReadyToRun=true + -p:CFBundleShortVersionString=$RELEASE_VERSION + -p:CFBundleName="Stability Matrix" + -p:CFBundleDisplayName="Stability Matrix" + -p:CFBundleVersion=$RELEASE_VERSION + -p:SentryOrg=${{ secrets.SENTRY_ORG }} -p:SentryProject=${{ secrets.SENTRY_PROJECT }} + -p:SentryUploadSymbols=true -p:SentryUploadSources=true + + - name: Post Build (App) + run: mkdir -p signing && mv "./StabilityMatrix.Avalonia/out/Stability Matrix.app" "./signing/${{ env.app-name }}" + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: ./Build/codesign_macos.sh "./signing/${{ env.app-name }}" + + - name: Notarize app bundle + env: + MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: ./Build/notarize_macos.sh "./signing/${{ env.app-name }}" + + - name: Zip Artifact (App) + working-directory: signing + run: zip -r -y "../StabilityMatrix-${{ env.platform-id }}-app.zip" "${{ env.app-name }}" + + - name: Upload Artifact (App) + uses: actions/upload-artifact@v2 + with: + name: StabilityMatrix-${{ env.platform-id }}-app + path: StabilityMatrix-${{ env.platform-id }}-app.zip + + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies for dmg creation + run: > + npm install --global create-dmg + brew install graphicsmagick imagemagick + + - name: Create dmg + working-directory: signing + run: > + create-dmg "${{ env.app-name }}" --overwrite --identity "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}" + + - name: Rename dmg + working-directory: signing + run: mv "$(find . -type f -name "*.dmg")" "${{ env.out-name }}" + + - name: Zip Artifact (dmg) + working-directory: signing + run: zip -r -y "../StabilityMatrix-${{ env.platform-id }}-dmg.zip" "${{ env.out-name }}" + + - name: Upload Artifact (dmg) + uses: actions/upload-artifact@v2 + with: + name: StabilityMatrix-${{ env.platform-id }}-dmg + path: StabilityMatrix-${{ env.platform-id }}-dmg.zip publish-release: name: Publish GitHub Release - needs: [ release-linux, release-windows ] + needs: [ release-linux, release-windows, release-macos ] if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.github-release == 'true' }} runs-on: ubuntu-latest steps: @@ -194,11 +309,12 @@ jobs: - name: Download Artifacts uses: actions/download-artifact@v3 - # Zip each build + # Zip each build (except macos which is already dmg) - name: Zip Artifacts run: | cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD + unzip "StabilityMatrix-osx-arm64-dmg/StabilityMatrix-osx-arm64-dmg.zip" - name: Create Github Release id: create_release @@ -209,15 +325,75 @@ jobs: files: | StabilityMatrix-win-x64.zip StabilityMatrix-linux-x64.zip + StabilityMatrix-macos-arm64.dmg fail_on_unmatched_files: true tag_name: v${{ github.event.inputs.version }} body: ${{ steps.release_notes.outputs.release_notes }} draft: ${{ github.event.inputs.github-release-draft == 'true' }} prerelease: ${{ github.event.inputs.github-release-prerelease == 'true' }} + test-artifacts: + name: Test Release Artifacts + needs: [ release-linux, release-windows, release-macos ] + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.test-release-artifacts == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Extract Release Notes + id: release_notes + run: | + RELEASE_NOTES="$(awk -v version="${{ github.event.inputs.version }}" '/## v/{if(p) exit; if($0 ~ version) p=1}; p' CHANGELOG.md)" + RELEASE_NOTES="${RELEASE_NOTES//'%'/'%25'}" + RELEASE_NOTES="${RELEASE_NOTES//$'\n'/'%0A'}" + RELEASE_NOTES="${RELEASE_NOTES//$'\r'/'%0D'}" + echo "::set-output name=release_notes::$RELEASE_NOTES" + echo "Release Notes:" + echo "$RELEASE_NOTES" + + # Downloads all previous artifacts to the current working directory + - name: Download Artifacts + uses: actions/download-artifact@v3 + + # Zip each build (except macos which is already dmg) + - name: Zip Artifacts + run: | + cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD + cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD + unzip "StabilityMatrix-osx-arm64-dmg/StabilityMatrix-osx-arm64-dmg.zip" + + # Check that the zips and CHANGELOG.md are in the current working directory + - name: Check files + run: | + if [ ! -f StabilityMatrix-win-x64.zip ]; then + echo "StabilityMatrix-win-x64.zip not found" + exit 1 + else + echo "StabilityMatrix-win-x64.zip found" + sha256sum StabilityMatrix-win-x64.zip + fi + if [ ! -f StabilityMatrix-linux-x64.zip ]; then + echo "StabilityMatrix-linux-x64.zip not found" + exit 1 + else + echo "StabilityMatrix-linux-x64.zip found" + sha256sum StabilityMatrix-linux-x64.zip + fi + if [ ! -f StabilityMatrix-macos-arm64.dmg ]; then + echo "StabilityMatrix-macos-arm64.dmg not found" + exit 1 + else + echo "StabilityMatrix-macos-arm64.dmg found" + sha256sum StabilityMatrix-macos-arm64.dmg + fi + if [ ! -f CHANGELOG.md ]; then + echo "CHANGELOG.md not found" + exit 1 + fi + publish-auto-update-github: name: Publish Auto-Update Release (GitHub) - needs: [ release-linux, release-windows ] + needs: [ release-linux, release-windows, release-macos ] if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auto-update-release == 'true' && github.event.inputs.auto-update-release-mode == 'github url' }} runs-on: ubuntu-latest steps: @@ -233,7 +409,7 @@ jobs: python-version: '3.11' - name: Install Python Dependencies - run: pip install stability-matrix-tools>=0.2.18 --upgrade + run: pip install stability-matrix-tools>=0.3.0 --upgrade - name: Publish Auto-Update Release env: @@ -246,7 +422,7 @@ jobs: publish-auto-update-b2: name: Publish Auto-Update Release (B2) - needs: [ release-linux, release-windows ] + needs: [ release-linux, release-windows, release-macos ] if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auto-update-release == 'true' && github.event.inputs.auto-update-release-mode == 'upload to b2' }} runs-on: ubuntu-latest steps: @@ -261,18 +437,19 @@ jobs: - name: Download Artifacts uses: actions/download-artifact@v3 - # Zip each build + # Zip each build (except macos which is already dmg) - name: Zip Artifacts run: | cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD + unzip "StabilityMatrix-osx-arm64-dmg/StabilityMatrix-osx-arm64-dmg.zip" - uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install Python Dependencies - run: pip install stability-matrix-tools>=0.2.18 --upgrade + run: pip install stability-matrix-tools>=0.3.0 --upgrade # Check that the zips and CHANGELOG.md are in the current working directory - name: Check files @@ -285,6 +462,10 @@ jobs: echo "StabilityMatrix-linux-x64.zip not found" exit 1 fi + if [ ! -f StabilityMatrix-macos-arm64.dmg ]; then + echo "StabilityMatrix-macos-arm64.dmg not found" + exit 1 + fi if [ ! -f CHANGELOG.md ]; then echo "CHANGELOG.md not found" exit 1 @@ -303,4 +484,5 @@ jobs: --changelog CHANGELOG.md --win-x64 StabilityMatrix-win-x64.zip --linux-x64 StabilityMatrix-linux-x64.zip + --macos-arm64 StabilityMatrix-macos-arm64.dmg -y diff --git a/Avalonia.Gif/Avalonia.Gif.csproj b/Avalonia.Gif/Avalonia.Gif.csproj new file mode 100644 index 00000000..1d2f4488 --- /dev/null +++ b/Avalonia.Gif/Avalonia.Gif.csproj @@ -0,0 +1,18 @@ + + + net8.0 + latest + true + win-x64;linux-x64;osx-x64;osx-arm64 + enable + enable + true + true + + + + + + + + diff --git a/Avalonia.Gif/BgWorkerCommand.cs b/Avalonia.Gif/BgWorkerCommand.cs new file mode 100644 index 00000000..aebd44dd --- /dev/null +++ b/Avalonia.Gif/BgWorkerCommand.cs @@ -0,0 +1,10 @@ +namespace Avalonia.Gif +{ + internal enum BgWorkerCommand + { + Null, + Play, + Pause, + Dispose + } +} diff --git a/Avalonia.Gif/BgWorkerState.cs b/Avalonia.Gif/BgWorkerState.cs new file mode 100644 index 00000000..1b09bba1 --- /dev/null +++ b/Avalonia.Gif/BgWorkerState.cs @@ -0,0 +1,12 @@ +namespace Avalonia.Gif +{ + internal enum BgWorkerState + { + Null, + Start, + Running, + Paused, + Complete, + Dispose + } +} diff --git a/Avalonia.Gif/Decoding/BlockTypes.cs b/Avalonia.Gif/Decoding/BlockTypes.cs new file mode 100644 index 00000000..2d804d5b --- /dev/null +++ b/Avalonia.Gif/Decoding/BlockTypes.cs @@ -0,0 +1,10 @@ +namespace Avalonia.Gif.Decoding +{ + internal enum BlockTypes + { + Empty = 0, + Extension = 0x21, + ImageDescriptor = 0x2C, + Trailer = 0x3B, + } +} diff --git a/Avalonia.Gif/Decoding/ExtensionType.cs b/Avalonia.Gif/Decoding/ExtensionType.cs new file mode 100644 index 00000000..5db6d575 --- /dev/null +++ b/Avalonia.Gif/Decoding/ExtensionType.cs @@ -0,0 +1,8 @@ +namespace Avalonia.Gif.Decoding +{ + internal enum ExtensionType + { + GraphicsControl = 0xF9, + Application = 0xFF + } +} diff --git a/Avalonia.Gif/Decoding/FrameDisposal.cs b/Avalonia.Gif/Decoding/FrameDisposal.cs new file mode 100644 index 00000000..bf4f00b7 --- /dev/null +++ b/Avalonia.Gif/Decoding/FrameDisposal.cs @@ -0,0 +1,10 @@ +namespace Avalonia.Gif.Decoding +{ + public enum FrameDisposal + { + Unknown = 0, + Leave = 1, + Background = 2, + Restore = 3 + } +} diff --git a/Avalonia.Gif/Decoding/GifColor.cs b/Avalonia.Gif/Decoding/GifColor.cs new file mode 100644 index 00000000..222d7303 --- /dev/null +++ b/Avalonia.Gif/Decoding/GifColor.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; + +namespace Avalonia.Gif +{ + [StructLayout(LayoutKind.Explicit)] + public readonly struct GifColor + { + [FieldOffset(3)] + public readonly byte A; + + [FieldOffset(2)] + public readonly byte R; + + [FieldOffset(1)] + public readonly byte G; + + [FieldOffset(0)] + public readonly byte B; + + /// + /// A struct that represents a ARGB color and is aligned as + /// a BGRA bytefield in memory. + /// + /// Red + /// Green + /// Blue + /// Alpha + public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue) + { + A = a; + R = r; + G = g; + B = b; + } + } +} diff --git a/Avalonia.Gif/Decoding/GifDecoder.cs b/Avalonia.Gif/Decoding/GifDecoder.cs new file mode 100644 index 00000000..adc26bb0 --- /dev/null +++ b/Avalonia.Gif/Decoding/GifDecoder.cs @@ -0,0 +1,653 @@ +// This source file's Lempel-Ziv-Welch algorithm is derived from Chromium's Android GifPlayer +// as seen here (https://github.com/chromium/chromium/blob/master/third_party/gif_player/src/jp/tomorrowkey/android/gifplayer) +// Licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +// Copyright (C) 2015 The Gifplayer Authors. All Rights Reserved. + +// The rest of the source file is licensed under MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Avalonia; +using Avalonia.Media.Imaging; +using static Avalonia.Gif.Extensions.StreamExtensions; + +namespace Avalonia.Gif.Decoding +{ + public sealed class GifDecoder : IDisposable + { + private static readonly ReadOnlyMemory G87AMagic = "GIF87a"u8.ToArray().AsMemory(); + + private static readonly ReadOnlyMemory G89AMagic = "GIF89a"u8.ToArray().AsMemory(); + + private static readonly ReadOnlyMemory NetscapeMagic = "NETSCAPE2.0"u8.ToArray().AsMemory(); + + private static readonly TimeSpan FrameDelayThreshold = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan FrameDelayDefault = TimeSpan.FromMilliseconds(100); + private static readonly GifColor TransparentColor = new(0, 0, 0, 0); + private static readonly int MaxTempBuf = 768; + private static readonly int MaxStackSize = 4096; + private static readonly int MaxBits = 4097; + + private readonly Stream _fileStream; + private readonly CancellationToken _currentCtsToken; + private readonly bool _hasFrameBackups; + + private int _gctSize, + _bgIndex, + _prevFrame = -1, + _backupFrame = -1; + private bool _gctUsed; + + private GifRect _gifDimensions; + + // private ulong _globalColorTable; + private readonly int _backBufferBytes; + private GifColor[] _bitmapBackBuffer; + + private short[] _prefixBuf; + private byte[] _suffixBuf; + private byte[] _pixelStack; + private byte[] _indexBuf; + private byte[] _backupFrameIndexBuf; + private volatile bool _hasNewFrame; + + public GifHeader Header { get; private set; } + + public readonly List Frames = new(); + + public PixelSize Size => new PixelSize(Header.Dimensions.Width, Header.Dimensions.Height); + + public GifDecoder(Stream fileStream, CancellationToken currentCtsToken) + { + _fileStream = fileStream; + _currentCtsToken = currentCtsToken; + + ProcessHeaderData(); + ProcessFrameData(); + + Header.IterationCount = Header.Iterations switch + { + -1 => new GifRepeatBehavior { Count = 1 }, + 0 => new GifRepeatBehavior { LoopForever = true }, + > 0 => new GifRepeatBehavior { Count = Header.Iterations }, + _ => Header.IterationCount + }; + + var pixelCount = _gifDimensions.TotalPixels; + + _hasFrameBackups = Frames.Any(f => f.FrameDisposalMethod == FrameDisposal.Restore); + + _bitmapBackBuffer = new GifColor[pixelCount]; + _indexBuf = new byte[pixelCount]; + + if (_hasFrameBackups) + _backupFrameIndexBuf = new byte[pixelCount]; + + _prefixBuf = new short[MaxStackSize]; + _suffixBuf = new byte[MaxStackSize]; + _pixelStack = new byte[MaxStackSize + 1]; + + _backBufferBytes = pixelCount * Marshal.SizeOf(typeof(GifColor)); + } + + public void Dispose() + { + Frames.Clear(); + + _bitmapBackBuffer = null; + _prefixBuf = null; + _suffixBuf = null; + _pixelStack = null; + _indexBuf = null; + _backupFrameIndexBuf = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int PixCoord(int x, int y) => x + y * _gifDimensions.Width; + + static readonly (int Start, int Step)[] Pass = { (0, 8), (4, 8), (2, 4), (1, 2) }; + + private void ClearImage() + { + Array.Fill(_bitmapBackBuffer, TransparentColor); + //ClearArea(_gifDimensions); + + _prevFrame = -1; + _backupFrame = -1; + } + + public void RenderFrame(int fIndex, WriteableBitmap writeableBitmap, bool forceClear = false) + { + if (_currentCtsToken.IsCancellationRequested) + return; + + if (fIndex < 0 | fIndex >= Frames.Count) + return; + + if (_prevFrame == fIndex) + return; + + if (fIndex == 0 || forceClear || fIndex < _prevFrame) + ClearImage(); + + DisposePreviousFrame(); + + _prevFrame++; + + // render intermediate frame + for (int idx = _prevFrame; idx < fIndex; ++idx) + { + var prevFrame = Frames[idx]; + + if (prevFrame.FrameDisposalMethod == FrameDisposal.Restore) + continue; + + if (prevFrame.FrameDisposalMethod == FrameDisposal.Background) + { + ClearArea(prevFrame.Dimensions); + continue; + } + + RenderFrameAt(idx, writeableBitmap); + } + + RenderFrameAt(fIndex, writeableBitmap); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RenderFrameAt(int idx, WriteableBitmap writeableBitmap) + { + var tmpB = ArrayPool.Shared.Rent(MaxTempBuf); + + var curFrame = Frames[idx]; + DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB); + + if (_hasFrameBackups & curFrame.ShouldBackup) + { + Buffer.BlockCopy(_indexBuf, 0, _backupFrameIndexBuf, 0, curFrame.Dimensions.TotalPixels); + _backupFrame = idx; + } + + DrawFrame(curFrame, _indexBuf); + + _prevFrame = idx; + _hasNewFrame = true; + + using var lockedBitmap = writeableBitmap.Lock(); + WriteBackBufToFb(lockedBitmap.Address); + + ArrayPool.Shared.Return(tmpB); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DrawFrame(GifFrame curFrame, Memory frameIndexSpan) + { + var activeColorTable = curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header.GlobarColorTable; + + var cX = curFrame.Dimensions.X; + var cY = curFrame.Dimensions.Y; + var cH = curFrame.Dimensions.Height; + var cW = curFrame.Dimensions.Width; + var tC = curFrame.TransparentColorIndex; + var hT = curFrame.HasTransparency; + + if (curFrame.IsInterlaced) + { + for (var i = 0; i < 4; i++) + { + var curPass = Pass[i]; + var y = curPass.Start; + while (y < cH) + { + DrawRow(y); + y += curPass.Step; + } + } + } + else + { + for (var i = 0; i < cH; i++) + DrawRow(i); + } + + //for (var row = 0; row < cH; row++) + void DrawRow(int row) + { + // Get the starting point of the current row on frame's index stream. + var indexOffset = row * cW; + + // Get the target backbuffer offset from the frames coords. + var targetOffset = PixCoord(cX, row + cY); + var len = _bitmapBackBuffer.Length; + + for (var i = 0; i < cW; i++) + { + var indexColor = frameIndexSpan.Span[indexOffset + i]; + + if (activeColorTable == null || targetOffset >= len || indexColor > activeColorTable.Length) + return; + + if (!(hT & indexColor == tC)) + _bitmapBackBuffer[targetOffset] = activeColorTable[indexColor]; + + targetOffset++; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DisposePreviousFrame() + { + if (_prevFrame == -1) + return; + + var prevFrame = Frames[_prevFrame]; + + switch (prevFrame.FrameDisposalMethod) + { + case FrameDisposal.Background: + ClearArea(prevFrame.Dimensions); + break; + case FrameDisposal.Restore: + if (_hasFrameBackups && _backupFrame != -1) + DrawFrame(Frames[_backupFrame], _backupFrameIndexBuf); + else + ClearArea(prevFrame.Dimensions); + break; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ClearArea(GifRect area) + { + for (var y = 0; y < area.Height; y++) + { + var targetOffset = PixCoord(area.X, y + area.Y); + for (var x = 0; x < area.Width; x++) + _bitmapBackBuffer[targetOffset + x] = TransparentColor; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DecompressFrameToIndexBuffer(GifFrame curFrame, Span indexSpan, byte[] tempBuf) + { + _fileStream.Position = curFrame.LzwStreamPosition; + var totalPixels = curFrame.Dimensions.TotalPixels; + + // Initialize GIF data stream decoder. + var dataSize = curFrame.LzwMinCodeSize; + var clear = 1 << dataSize; + var endOfInformation = clear + 1; + var available = clear + 2; + var oldCode = -1; + var codeSize = dataSize + 1; + var codeMask = (1 << codeSize) - 1; + + for (var code = 0; code < clear; code++) + { + _prefixBuf[code] = 0; + _suffixBuf[code] = (byte)code; + } + + // Decode GIF pixel stream. + int bits, + first, + top, + pixelIndex; + var datum = bits = first = top = pixelIndex = 0; + + while (pixelIndex < totalPixels) + { + var blockSize = _fileStream.ReadBlock(tempBuf); + + if (blockSize == 0) + break; + + var blockPos = 0; + + while (blockPos < blockSize) + { + datum += tempBuf[blockPos] << bits; + blockPos++; + + bits += 8; + + while (bits >= codeSize) + { + // Get the next code. + var code = datum & codeMask; + datum >>= codeSize; + bits -= codeSize; + + // Interpret the code + if (code == clear) + { + // Reset decoder. + codeSize = dataSize + 1; + codeMask = (1 << codeSize) - 1; + available = clear + 2; + oldCode = -1; + continue; + } + + // Check for explicit end-of-stream + if (code == endOfInformation) + return; + + if (oldCode == -1) + { + indexSpan[pixelIndex++] = _suffixBuf[code]; + oldCode = code; + first = code; + continue; + } + + var inCode = code; + if (code >= available) + { + _pixelStack[top++] = (byte)first; + code = oldCode; + + if (top == MaxBits) + ThrowException(); + } + + while (code >= clear) + { + if (code >= MaxBits || code == _prefixBuf[code]) + ThrowException(); + + _pixelStack[top++] = _suffixBuf[code]; + code = _prefixBuf[code]; + + if (top == MaxBits) + ThrowException(); + } + + first = _suffixBuf[code]; + _pixelStack[top++] = (byte)first; + + // Add new code to the dictionary + if (available < MaxStackSize) + { + _prefixBuf[available] = (short)oldCode; + _suffixBuf[available] = (byte)first; + available++; + + if ((available & codeMask) == 0 && available < MaxStackSize) + { + codeSize++; + codeMask += available; + } + } + + oldCode = inCode; + + // Drain the pixel stack. + do + { + indexSpan[pixelIndex++] = _pixelStack[--top]; + } while (top > 0); + } + } + } + + while (pixelIndex < totalPixels) + indexSpan[pixelIndex++] = 0; // clear missing pixels + + void ThrowException() => throw new LzwDecompressionException(); + } + + /// + /// Directly copies the struct array to a bitmap IntPtr. + /// + private void WriteBackBufToFb(IntPtr targetPointer) + { + if (_currentCtsToken.IsCancellationRequested) + return; + + if (!(_hasNewFrame & _bitmapBackBuffer != null)) + return; + + unsafe + { + fixed (void* src = &_bitmapBackBuffer[0]) + Buffer.MemoryCopy(src, targetPointer.ToPointer(), (uint)_backBufferBytes, (uint)_backBufferBytes); + _hasNewFrame = false; + } + } + + /// + /// Processes GIF Header. + /// + private void ProcessHeaderData() + { + var str = _fileStream; + var tmpB = ArrayPool.Shared.Rent(MaxTempBuf); + var tempBuf = tmpB.AsSpan(); + + var _ = str.Read(tmpB, 0, 6); + + if (!tempBuf[..3].SequenceEqual(G87AMagic[..3].Span)) + throw new InvalidGifStreamException("Not a GIF stream."); + + if (!(tempBuf[..6].SequenceEqual(G87AMagic.Span) | tempBuf[..6].SequenceEqual(G89AMagic.Span))) + throw new InvalidGifStreamException( + "Unsupported GIF Version: " + Encoding.ASCII.GetString(tempBuf[..6].ToArray()) + ); + + ProcessScreenDescriptor(tmpB); + + Header = new GifHeader + { + Dimensions = _gifDimensions, + HasGlobalColorTable = _gctUsed, + // GlobalColorTableCacheID = _globalColorTable, + GlobarColorTable = ProcessColorTable(ref str, tmpB, _gctSize), + GlobalColorTableSize = _gctSize, + BackgroundColorIndex = _bgIndex, + HeaderSize = _fileStream.Position + }; + + ArrayPool.Shared.Return(tmpB); + } + + /// + /// Parses colors from file stream to target color table. + /// + private static GifColor[] ProcessColorTable(ref Stream stream, byte[] rawBufSpan, int nColors) + { + var nBytes = 3 * nColors; + var target = new GifColor[nColors]; + + var n = stream.Read(rawBufSpan, 0, nBytes); + + if (n < nBytes) + throw new InvalidOperationException("Wrong color table bytes."); + + int i = 0, + j = 0; + + while (i < nColors) + { + var r = rawBufSpan[j++]; + var g = rawBufSpan[j++]; + var b = rawBufSpan[j++]; + target[i++] = new GifColor(r, g, b); + } + + return target; + } + + /// + /// Parses screen and other GIF descriptors. + /// + private void ProcessScreenDescriptor(byte[] tempBuf) + { + var width = _fileStream.ReadUShortS(tempBuf); + var height = _fileStream.ReadUShortS(tempBuf); + + var packed = _fileStream.ReadByteS(tempBuf); + + _gctUsed = (packed & 0x80) != 0; + _gctSize = 2 << (packed & 7); + _bgIndex = _fileStream.ReadByteS(tempBuf); + + _gifDimensions = new GifRect(0, 0, width, height); + _fileStream.Skip(1); + } + + /// + /// Parses all frame data. + /// + private void ProcessFrameData() + { + _fileStream.Position = Header.HeaderSize; + + var tempBuf = ArrayPool.Shared.Rent(MaxTempBuf); + + var terminate = false; + var curFrame = 0; + + Frames.Add(new GifFrame()); + + do + { + var blockType = (BlockTypes)_fileStream.ReadByteS(tempBuf); + + switch (blockType) + { + case BlockTypes.Empty: + break; + + case BlockTypes.Extension: + ProcessExtensions(ref curFrame, tempBuf); + break; + + case BlockTypes.ImageDescriptor: + ProcessImageDescriptor(ref curFrame, tempBuf); + _fileStream.SkipBlocks(tempBuf); + break; + + case BlockTypes.Trailer: + Frames.RemoveAt(Frames.Count - 1); + terminate = true; + break; + + default: + _fileStream.SkipBlocks(tempBuf); + break; + } + + // Break the loop when the stream is not valid anymore. + if (_fileStream.Position >= _fileStream.Length & terminate == false) + throw new InvalidProgramException("Reach the end of the filestream without trailer block."); + } while (!terminate); + + ArrayPool.Shared.Return(tempBuf); + } + + /// + /// Parses GIF Image Descriptor Block. + /// + private void ProcessImageDescriptor(ref int curFrame, byte[] tempBuf) + { + var str = _fileStream; + var currentFrame = Frames[curFrame]; + + // Parse frame dimensions. + var frameX = str.ReadUShortS(tempBuf); + var frameY = str.ReadUShortS(tempBuf); + var frameW = str.ReadUShortS(tempBuf); + var frameH = str.ReadUShortS(tempBuf); + + frameW = (ushort)Math.Min(frameW, _gifDimensions.Width - frameX); + frameH = (ushort)Math.Min(frameH, _gifDimensions.Height - frameY); + + currentFrame.Dimensions = new GifRect(frameX, frameY, frameW, frameH); + + // Unpack interlace and lct info. + var packed = str.ReadByteS(tempBuf); + currentFrame.IsInterlaced = (packed & 0x40) != 0; + currentFrame.IsLocalColorTableUsed = (packed & 0x80) != 0; + currentFrame.LocalColorTableSize = (int)Math.Pow(2, (packed & 0x07) + 1); + + if (currentFrame.IsLocalColorTableUsed) + currentFrame.LocalColorTable = ProcessColorTable(ref str, tempBuf, currentFrame.LocalColorTableSize); + + currentFrame.LzwMinCodeSize = str.ReadByteS(tempBuf); + currentFrame.LzwStreamPosition = str.Position; + + curFrame += 1; + Frames.Add(new GifFrame()); + } + + /// + /// Parses GIF Extension Blocks. + /// + private void ProcessExtensions(ref int curFrame, byte[] tempBuf) + { + var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf); + + switch (extType) + { + case ExtensionType.GraphicsControl: + + _fileStream.ReadBlock(tempBuf); + var currentFrame = Frames[curFrame]; + var packed = tempBuf[0]; + + currentFrame.FrameDisposalMethod = (FrameDisposal)((packed & 0x1c) >> 2); + + if ( + currentFrame.FrameDisposalMethod != FrameDisposal.Restore + && currentFrame.FrameDisposalMethod != FrameDisposal.Background + ) + currentFrame.ShouldBackup = true; + + currentFrame.HasTransparency = (packed & 1) != 0; + + currentFrame.FrameDelay = TimeSpan.FromMilliseconds(SpanToShort(tempBuf.AsSpan(1)) * 10); + + if (currentFrame.FrameDelay <= FrameDelayThreshold) + currentFrame.FrameDelay = FrameDelayDefault; + + currentFrame.TransparentColorIndex = tempBuf[3]; + break; + + case ExtensionType.Application: + var blockLen = _fileStream.ReadBlock(tempBuf); + var _ = tempBuf.AsSpan(0, blockLen); + var blockHeader = tempBuf.AsSpan(0, NetscapeMagic.Length); + + if (blockHeader.SequenceEqual(NetscapeMagic.Span)) + { + var count = 1; + + while (count > 0) + count = _fileStream.ReadBlock(tempBuf); + + var iterationCount = SpanToShort(tempBuf.AsSpan(1)); + + Header.Iterations = iterationCount; + } + else + _fileStream.SkipBlocks(tempBuf); + + break; + + default: + _fileStream.SkipBlocks(tempBuf); + break; + } + } + } +} diff --git a/Avalonia.Gif/Decoding/GifFrame.cs b/Avalonia.Gif/Decoding/GifFrame.cs new file mode 100644 index 00000000..ea0e6640 --- /dev/null +++ b/Avalonia.Gif/Decoding/GifFrame.cs @@ -0,0 +1,20 @@ +using System; + +namespace Avalonia.Gif.Decoding +{ + public class GifFrame + { + public bool HasTransparency, + IsInterlaced, + IsLocalColorTableUsed; + public byte TransparentColorIndex; + public int LzwMinCodeSize, + LocalColorTableSize; + public long LzwStreamPosition; + public TimeSpan FrameDelay; + public FrameDisposal FrameDisposalMethod; + public bool ShouldBackup; + public GifRect Dimensions; + public GifColor[] LocalColorTable; + } +} diff --git a/Avalonia.Gif/Decoding/GifHeader.cs b/Avalonia.Gif/Decoding/GifHeader.cs new file mode 100644 index 00000000..16638f79 --- /dev/null +++ b/Avalonia.Gif/Decoding/GifHeader.cs @@ -0,0 +1,19 @@ +// Licensed under the MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +namespace Avalonia.Gif.Decoding +{ + public class GifHeader + { + public bool HasGlobalColorTable; + public int GlobalColorTableSize; + public ulong GlobalColorTableCacheId; + public int BackgroundColorIndex; + public long HeaderSize; + internal int Iterations = -1; + public GifRepeatBehavior IterationCount; + public GifRect Dimensions; + private GifColor[] _globarColorTable; + public GifColor[] GlobarColorTable; + } +} diff --git a/Avalonia.Gif/Decoding/GifRect.cs b/Avalonia.Gif/Decoding/GifRect.cs new file mode 100644 index 00000000..01f621de --- /dev/null +++ b/Avalonia.Gif/Decoding/GifRect.cs @@ -0,0 +1,43 @@ +namespace Avalonia.Gif.Decoding +{ + public readonly struct GifRect + { + public int X { get; } + public int Y { get; } + public int Width { get; } + public int Height { get; } + public int TotalPixels { get; } + + public GifRect(int x, int y, int width, int height) + { + X = x; + Y = y; + Width = width; + Height = height; + TotalPixels = width * height; + } + + public static bool operator ==(GifRect a, GifRect b) + { + return a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height; + } + + public static bool operator !=(GifRect a, GifRect b) + { + return !(a == b); + } + + public override bool Equals(object obj) + { + if (obj == null || GetType() != obj.GetType()) + return false; + + return this == (GifRect)obj; + } + + public override int GetHashCode() + { + return X.GetHashCode() ^ Y.GetHashCode() | Width.GetHashCode() ^ Height.GetHashCode(); + } + } +} diff --git a/Avalonia.Gif/Decoding/GifRepeatBehavior.cs b/Avalonia.Gif/Decoding/GifRepeatBehavior.cs new file mode 100644 index 00000000..4b27a7bb --- /dev/null +++ b/Avalonia.Gif/Decoding/GifRepeatBehavior.cs @@ -0,0 +1,8 @@ +namespace Avalonia.Gif.Decoding +{ + public class GifRepeatBehavior + { + public bool LoopForever { get; set; } + public int? Count { get; set; } + } +} diff --git a/Avalonia.Gif/Decoding/InvalidGifStreamException.cs b/Avalonia.Gif/Decoding/InvalidGifStreamException.cs new file mode 100644 index 00000000..b3554bac --- /dev/null +++ b/Avalonia.Gif/Decoding/InvalidGifStreamException.cs @@ -0,0 +1,23 @@ +// Licensed under the MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +using System; +using System.Runtime.Serialization; + +namespace Avalonia.Gif.Decoding +{ + [Serializable] + public class InvalidGifStreamException : Exception + { + public InvalidGifStreamException() { } + + public InvalidGifStreamException(string message) + : base(message) { } + + public InvalidGifStreamException(string message, Exception innerException) + : base(message, innerException) { } + + protected InvalidGifStreamException(SerializationInfo info, StreamingContext context) + : base(info, context) { } + } +} diff --git a/Avalonia.Gif/Decoding/LzwDecompressionException.cs b/Avalonia.Gif/Decoding/LzwDecompressionException.cs new file mode 100644 index 00000000..ed25c0aa --- /dev/null +++ b/Avalonia.Gif/Decoding/LzwDecompressionException.cs @@ -0,0 +1,23 @@ +// Licensed under the MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +using System; +using System.Runtime.Serialization; + +namespace Avalonia.Gif.Decoding +{ + [Serializable] + public class LzwDecompressionException : Exception + { + public LzwDecompressionException() { } + + public LzwDecompressionException(string message) + : base(message) { } + + public LzwDecompressionException(string message, Exception innerException) + : base(message, innerException) { } + + protected LzwDecompressionException(SerializationInfo info, StreamingContext context) + : base(info, context) { } + } +} diff --git a/Avalonia.Gif/Extensions/StreamExtensions.cs b/Avalonia.Gif/Extensions/StreamExtensions.cs new file mode 100644 index 00000000..ac08fa68 --- /dev/null +++ b/Avalonia.Gif/Extensions/StreamExtensions.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; + +namespace Avalonia.Gif.Extensions +{ + [DebuggerStepThrough] + internal static class StreamExtensions + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort SpanToShort(Span b) => (ushort)(b[0] | (b[1] << 8)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Skip(this Stream stream, long count) + { + stream.Position += count; + } + + /// + /// Read a Gif block from stream while advancing the position. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ReadBlock(this Stream stream, byte[] tempBuf) + { + stream.Read(tempBuf, 0, 1); + + var blockLength = (int)tempBuf[0]; + + if (blockLength > 0) + stream.Read(tempBuf, 0, blockLength); + + // Guard against infinite loop. + if (stream.Position >= stream.Length) + throw new InvalidGifStreamException("Reach the end of the filestream without trailer block."); + + return blockLength; + } + + /// + /// Skips GIF blocks until it encounters an empty block. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SkipBlocks(this Stream stream, byte[] tempBuf) + { + int blockLength; + do + { + stream.Read(tempBuf, 0, 1); + + blockLength = tempBuf[0]; + stream.Position += blockLength; + + // Guard against infinite loop. + if (stream.Position >= stream.Length) + throw new InvalidGifStreamException("Reach the end of the filestream without trailer block."); + } while (blockLength > 0); + } + + /// + /// Read a from stream by providing a temporary buffer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort ReadUShortS(this Stream stream, byte[] tempBuf) + { + stream.Read(tempBuf, 0, 2); + return SpanToShort(tempBuf); + } + + /// + /// Read a from stream by providing a temporary buffer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte ReadByteS(this Stream stream, byte[] tempBuf) + { + stream.Read(tempBuf, 0, 1); + var finalVal = tempBuf[0]; + return finalVal; + } + } +} diff --git a/Avalonia.Gif/GifImage.cs b/Avalonia.Gif/GifImage.cs new file mode 100644 index 00000000..0ce8dca8 --- /dev/null +++ b/Avalonia.Gif/GifImage.cs @@ -0,0 +1,297 @@ +using System; +using System.IO; +using System.Numerics; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Logging; +using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.VisualTree; + +namespace Avalonia.Gif +{ + public class GifImage : Control + { + public static readonly StyledProperty SourceUriRawProperty = AvaloniaProperty.Register< + GifImage, + string + >("SourceUriRaw"); + + public static readonly StyledProperty SourceUriProperty = AvaloniaProperty.Register( + "SourceUri" + ); + + public static readonly StyledProperty SourceStreamProperty = AvaloniaProperty.Register< + GifImage, + Stream + >("SourceStream"); + + public static readonly StyledProperty IterationCountProperty = AvaloniaProperty.Register< + GifImage, + IterationCount + >("IterationCount", IterationCount.Infinite); + + private IGifInstance? _gifInstance; + + public static readonly StyledProperty StretchDirectionProperty = AvaloniaProperty.Register< + GifImage, + StretchDirection + >("StretchDirection"); + + public static readonly StyledProperty StretchProperty = AvaloniaProperty.Register( + "Stretch" + ); + + private CompositionCustomVisual? _customVisual; + + private object? _initialSource = null; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + switch (change.Property.Name) + { + case nameof(SourceUriRaw): + case nameof(SourceUri): + case nameof(SourceStream): + SourceChanged(change); + break; + case nameof(Stretch): + case nameof(StretchDirection): + InvalidateArrange(); + InvalidateMeasure(); + Update(); + break; + case nameof(IterationCount): + IterationCountChanged(change); + break; + case nameof(Bounds): + Update(); + break; + } + + base.OnPropertyChanged(change); + } + + public string SourceUriRaw + { + get => GetValue(SourceUriRawProperty); + set => SetValue(SourceUriRawProperty, value); + } + + public Uri SourceUri + { + get => GetValue(SourceUriProperty); + set => SetValue(SourceUriProperty, value); + } + + public Stream SourceStream + { + get => GetValue(SourceStreamProperty); + set => SetValue(SourceStreamProperty, value); + } + + public IterationCount IterationCount + { + get => GetValue(IterationCountProperty); + set => SetValue(IterationCountProperty, value); + } + + public StretchDirection StretchDirection + { + get => GetValue(StretchDirectionProperty); + set => SetValue(StretchDirectionProperty, value); + } + + public Stretch Stretch + { + get => GetValue(StretchProperty); + set => SetValue(StretchProperty, value); + } + + private static void IterationCountChanged(AvaloniaPropertyChangedEventArgs e) + { + var image = e.Sender as GifImage; + if (image is null || e.NewValue is not IterationCount iterationCount) + return; + + image.IterationCount = iterationCount; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + var compositor = ElementComposition.GetElementVisual(this)?.Compositor; + if (compositor == null || _customVisual?.Compositor == compositor) + return; + _customVisual = compositor.CreateCustomVisual(new CustomVisualHandler()); + ElementComposition.SetElementChildVisual(this, _customVisual); + _customVisual.SendHandlerMessage(CustomVisualHandler.StartMessage); + + if (_initialSource is not null) + { + UpdateGifInstance(_initialSource); + _initialSource = null; + } + + Update(); + base.OnAttachedToVisualTree(e); + } + + private void Update() + { + if (_customVisual is null || _gifInstance is null) + return; + + var dpi = this.GetVisualRoot()?.RenderScaling ?? 1.0; + var sourceSize = _gifInstance.GifPixelSize.ToSize(dpi); + var viewPort = new Rect(Bounds.Size); + + var scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection); + var scaledSize = sourceSize * scale; + var destRect = viewPort.CenterRect(new Rect(scaledSize)).Intersect(viewPort); + + if (Stretch == Stretch.None) + { + _customVisual.Size = new Vector2((float)sourceSize.Width, (float)sourceSize.Height); + } + else + { + _customVisual.Size = new Vector2((float)destRect.Size.Width, (float)destRect.Size.Height); + } + + _customVisual.Offset = new Vector3((float)destRect.Position.X, (float)destRect.Position.Y, 0); + } + + private class CustomVisualHandler : CompositionCustomVisualHandler + { + private TimeSpan _animationElapsed; + private TimeSpan? _lastServerTime; + private IGifInstance? _currentInstance; + private bool _running; + + public static readonly object StopMessage = new(), + StartMessage = new(); + + public override void OnMessage(object message) + { + if (message == StartMessage) + { + _running = true; + _lastServerTime = null; + RegisterForNextAnimationFrameUpdate(); + } + else if (message == StopMessage) + { + _running = false; + } + else if (message is IGifInstance instance) + { + _currentInstance?.Dispose(); + _currentInstance = instance; + } + } + + public override void OnAnimationFrameUpdate() + { + if (!_running) + return; + Invalidate(); + RegisterForNextAnimationFrameUpdate(); + } + + public override void OnRender(ImmediateDrawingContext drawingContext) + { + if (_running) + { + if (_lastServerTime.HasValue) + _animationElapsed += (CompositionNow - _lastServerTime.Value); + _lastServerTime = CompositionNow; + } + + try + { + if (_currentInstance is null || _currentInstance.IsDisposed) + return; + + var bitmap = _currentInstance.ProcessFrameTime(_animationElapsed); + if (bitmap is not null) + { + drawingContext.DrawBitmap( + bitmap, + new Rect(_currentInstance.GifPixelSize.ToSize(1)), + GetRenderBounds() + ); + } + } + catch (Exception e) + { + Logger.Sink?.Log(LogEventLevel.Error, "GifImage Renderer ", this, e.ToString()); + } + } + } + + /// + /// Measures the control. + /// + /// The available size. + /// The desired size of the control. + protected override Size MeasureOverride(Size availableSize) + { + var result = new Size(); + var scaling = this.GetVisualRoot()?.RenderScaling ?? 1.0; + if (_gifInstance != null) + { + result = Stretch.CalculateSize( + availableSize, + _gifInstance.GifPixelSize.ToSize(scaling), + StretchDirection + ); + } + + return result; + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + if (_gifInstance is null) + return new Size(); + var scaling = this.GetVisualRoot()?.RenderScaling ?? 1.0; + var sourceSize = _gifInstance.GifPixelSize.ToSize(scaling); + var result = Stretch.CalculateSize(finalSize, sourceSize); + return result; + } + + private void SourceChanged(AvaloniaPropertyChangedEventArgs e) + { + if ( + e.NewValue is null + || (e.NewValue is string value && !Uri.IsWellFormedUriString(value, UriKind.Absolute)) + ) + { + return; + } + + if (_customVisual is null) + { + _initialSource = e.NewValue; + return; + } + + UpdateGifInstance(e.NewValue); + + InvalidateArrange(); + InvalidateMeasure(); + Update(); + } + + private void UpdateGifInstance(object source) + { + _gifInstance?.Dispose(); + _gifInstance = new WebpInstance(source); + // _gifInstance = new GifInstance(source); + _gifInstance.IterationCount = IterationCount; + _customVisual?.SendHandlerMessage(_gifInstance); + } + } +} diff --git a/Avalonia.Gif/GifInstance.cs b/Avalonia.Gif/GifInstance.cs new file mode 100644 index 00000000..b97badaa --- /dev/null +++ b/Avalonia.Gif/GifInstance.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Gif.Decoding; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace Avalonia.Gif +{ + public class GifInstance : IGifInstance + { + public IterationCount IterationCount { get; set; } + public bool AutoStart { get; private set; } = true; + private readonly GifDecoder _gifDecoder; + private readonly WriteableBitmap? _targetBitmap; + private TimeSpan _totalTime; + private readonly List _frameTimes; + private uint _iterationCount; + private int _currentFrameIndex; + private readonly List _colorTableIdList; + + public CancellationTokenSource CurrentCts { get; } + + internal GifInstance(object newValue) + : this( + newValue switch + { + Stream s => s, + Uri u => GetStreamFromUri(u), + string str => GetStreamFromString(str), + _ => throw new InvalidDataException("Unsupported source object") + } + ) { } + + public GifInstance(string uri) + : this(GetStreamFromString(uri)) { } + + public GifInstance(Uri uri) + : this(GetStreamFromUri(uri)) { } + + public GifInstance(Stream currentStream) + { + if (!currentStream.CanSeek) + throw new InvalidDataException("The provided stream is not seekable."); + + if (!currentStream.CanRead) + throw new InvalidOperationException("Can't read the stream provided."); + + currentStream.Seek(0, SeekOrigin.Begin); + + CurrentCts = new CancellationTokenSource(); + + _gifDecoder = new GifDecoder(currentStream, CurrentCts.Token); + var pixSize = new PixelSize(_gifDecoder.Header.Dimensions.Width, _gifDecoder.Header.Dimensions.Height); + + _targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque); + GifPixelSize = pixSize; + + _totalTime = TimeSpan.Zero; + + _frameTimes = _gifDecoder + .Frames + .Select(frame => + { + _totalTime = _totalTime.Add(frame.FrameDelay); + return _totalTime; + }) + .ToList(); + + _gifDecoder.RenderFrame(0, _targetBitmap); + } + + private static Stream GetStreamFromString(string str) + { + if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res)) + { + throw new InvalidCastException("The string provided can't be converted to URI."); + } + + return GetStreamFromUri(res); + } + + private static Stream GetStreamFromUri(Uri uri) + { + var uriString = uri.OriginalString.Trim(); + + if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares")) + { + return new FileStream(uriString, FileMode.Open, FileAccess.Read); + } + + return AssetLoader.Open(uri); + } + + public int GifFrameCount => _frameTimes.Count; + + public PixelSize GifPixelSize { get; } + + public void Dispose() + { + IsDisposed = true; + CurrentCts.Cancel(); + _targetBitmap?.Dispose(); + } + + public bool IsDisposed { get; private set; } + + public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed) + { + if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value) + { + return null; + } + + if (CurrentCts.IsCancellationRequested || _targetBitmap is null) + { + return null; + } + + var elapsedTicks = stopwatchElapsed.Ticks; + var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks); + var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x); + var currentFrame = _frameTimes.IndexOf(targetFrame); + if (currentFrame == -1) + currentFrame = 0; + + if (_currentFrameIndex == currentFrame) + return _targetBitmap; + + _iterationCount = (uint)(elapsedTicks / _totalTime.Ticks); + + return ProcessFrameIndex(currentFrame); + } + + internal WriteableBitmap ProcessFrameIndex(int frameIndex) + { + _gifDecoder.RenderFrame(frameIndex, _targetBitmap); + _currentFrameIndex = frameIndex; + + return _targetBitmap; + } + } +} diff --git a/Avalonia.Gif/IGifInstance.cs b/Avalonia.Gif/IGifInstance.cs new file mode 100644 index 00000000..667f9163 --- /dev/null +++ b/Avalonia.Gif/IGifInstance.cs @@ -0,0 +1,15 @@ +using Avalonia.Animation; +using Avalonia.Media.Imaging; + +namespace Avalonia.Gif; + +public interface IGifInstance : IDisposable +{ + IterationCount IterationCount { get; set; } + bool AutoStart { get; } + CancellationTokenSource CurrentCts { get; } + int GifFrameCount { get; } + PixelSize GifPixelSize { get; } + bool IsDisposed { get; } + WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed); +} diff --git a/Avalonia.Gif/InvalidGifStreamException.cs b/Avalonia.Gif/InvalidGifStreamException.cs new file mode 100644 index 00000000..9771d9cb --- /dev/null +++ b/Avalonia.Gif/InvalidGifStreamException.cs @@ -0,0 +1,20 @@ +using System; +using System.Runtime.Serialization; + +namespace Avalonia.Gif +{ + [Serializable] + internal class InvalidGifStreamException : Exception + { + public InvalidGifStreamException() { } + + public InvalidGifStreamException(string message) + : base(message) { } + + public InvalidGifStreamException(string message, Exception innerException) + : base(message, innerException) { } + + protected InvalidGifStreamException(SerializationInfo info, StreamingContext context) + : base(info, context) { } + } +} diff --git a/Avalonia.Gif/WebpInstance.cs b/Avalonia.Gif/WebpInstance.cs new file mode 100644 index 00000000..b9256934 --- /dev/null +++ b/Avalonia.Gif/WebpInstance.cs @@ -0,0 +1,180 @@ +using Avalonia.Animation; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using SkiaSharp; + +namespace Avalonia.Gif; + +public class WebpInstance : IGifInstance +{ + public IterationCount IterationCount { get; set; } + public bool AutoStart { get; private set; } = true; + + private readonly WriteableBitmap? _targetBitmap; + private TimeSpan _totalTime; + private readonly List _frameTimes; + private uint _iterationCount; + private int _currentFrameIndex; + + private SKCodec? _codec; + + public CancellationTokenSource CurrentCts { get; } + + internal WebpInstance(object newValue) + : this( + newValue switch + { + Stream s => s, + Uri u => GetStreamFromUri(u), + string str => GetStreamFromString(str), + _ => throw new InvalidDataException("Unsupported source object") + } + ) { } + + public WebpInstance(string uri) + : this(GetStreamFromString(uri)) { } + + public WebpInstance(Uri uri) + : this(GetStreamFromUri(uri)) { } + + public WebpInstance(Stream currentStream) + { + if (!currentStream.CanSeek) + throw new InvalidDataException("The provided stream is not seekable."); + + if (!currentStream.CanRead) + throw new InvalidOperationException("Can't read the stream provided."); + + currentStream.Seek(0, SeekOrigin.Begin); + + CurrentCts = new CancellationTokenSource(); + + var managedStream = new SKManagedStream(currentStream); + _codec = SKCodec.Create(managedStream); + + var pixSize = new PixelSize(_codec.Info.Width, _codec.Info.Height); + + _targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque); + GifPixelSize = pixSize; + + _totalTime = TimeSpan.Zero; + + _frameTimes = _codec + .FrameInfo + .Select(frame => + { + _totalTime = _totalTime.Add(TimeSpan.FromMilliseconds(frame.Duration)); + return _totalTime; + }) + .ToList(); + + RenderFrame(_codec, _targetBitmap, 0); + } + + private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index) + { + codec.GetFrameInfo(index, out var frameInfo); + + var info = new SKImageInfo(codec.Info.Width, codec.Info.Height); + var decodeInfo = info.WithAlphaType(frameInfo.AlphaType); + + using var frameBuffer = targetBitmap.Lock(); + + var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index)); + + if (result != SKCodecResult.Success) + throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}."); + } + + private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index, int priorIndex) + { + codec.GetFrameInfo(index, out var frameInfo); + + var info = new SKImageInfo(codec.Info.Width, codec.Info.Height); + var decodeInfo = info.WithAlphaType(frameInfo.AlphaType); + + using var frameBuffer = targetBitmap.Lock(); + + var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index, priorIndex)); + + if (result != SKCodecResult.Success) + throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}."); + } + + private static Stream GetStreamFromString(string str) + { + if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res)) + { + throw new InvalidCastException("The string provided can't be converted to URI."); + } + + return GetStreamFromUri(res); + } + + private static Stream GetStreamFromUri(Uri uri) + { + var uriString = uri.OriginalString.Trim(); + + if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares")) + { + return new FileStream(uriString, FileMode.Open, FileAccess.Read); + } + + return AssetLoader.Open(uri); + } + + public int GifFrameCount => _frameTimes.Count; + + public PixelSize GifPixelSize { get; } + + public void Dispose() + { + IsDisposed = true; + CurrentCts.Cancel(); + _targetBitmap?.Dispose(); + _codec?.Dispose(); + } + + public bool IsDisposed { get; private set; } + + public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed) + { + if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value) + { + return null; + } + + if (CurrentCts.IsCancellationRequested || _targetBitmap is null) + { + return null; + } + + var elapsedTicks = stopwatchElapsed.Ticks; + var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks); + var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x); + var currentFrame = _frameTimes.IndexOf(targetFrame); + if (currentFrame == -1) + currentFrame = 0; + + if (_currentFrameIndex == currentFrame) + return _targetBitmap; + + _iterationCount = (uint)(elapsedTicks / _totalTime.Ticks); + + return ProcessFrameIndex(currentFrame); + } + + internal WriteableBitmap ProcessFrameIndex(int frameIndex) + { + if (_codec is null) + throw new InvalidOperationException("The codec is null."); + + if (_targetBitmap is null) + throw new InvalidOperationException("The target bitmap is null."); + + RenderFrame(_codec, _targetBitmap, frameIndex, _currentFrameIndex); + _currentFrameIndex = frameIndex; + + return _targetBitmap; + } +} diff --git a/Build/AppEntitlements.entitlements b/Build/AppEntitlements.entitlements new file mode 100644 index 00000000..885de0b5 --- /dev/null +++ b/Build/AppEntitlements.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/Build/EmbeddedEntitlements.entitlements b/Build/EmbeddedEntitlements.entitlements new file mode 100644 index 00000000..c4317486 --- /dev/null +++ b/Build/EmbeddedEntitlements.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/Build/build_macos_app.sh b/Build/build_macos_app.sh new file mode 100755 index 00000000..acdfcd4b --- /dev/null +++ b/Build/build_macos_app.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +while getopts v: flag +do + case "${flag}" in + v) version=${OPTARG};; + *) echo "Invalid option";; + esac +done + +dotnet \ +msbuild \ +StabilityMatrix.Avalonia \ +-t:BundleApp \ +-p:RuntimeIdentifier=osx-arm64 \ +-p:UseAppHost=true \ +-p:Configuration=Release \ +-p:CFBundleShortVersionString="$version" \ +-p:SelfContained=true \ +-p:CFBundleName="Stability Matrix" \ +-p:CFBundleDisplayName="Stability Matrix" \ +-p:CFBundleVersion="$version" \ +-p:PublishDir="$(pwd)/out/osx-arm64/bin" \ + +# Copy the app out of bin +cp -r ./out/osx-arm64/bin/Stability\ Matrix.app ./out/osx-arm64/Stability\ Matrix.app diff --git a/Build/codesign_embedded_macos.sh b/Build/codesign_embedded_macos.sh new file mode 100755 index 00000000..c0a99ea2 --- /dev/null +++ b/Build/codesign_embedded_macos.sh @@ -0,0 +1,62 @@ +#!/bin/sh + +echo "Signing file: $1" + +# Setup keychain in CI +if [ -n "$CI" ]; then + # Turn our base64-encoded certificate back to a regular .p12 file + + echo "$MACOS_CERTIFICATE" | base64 --decode -o certificate.p12 + + # We need to create a new keychain, otherwise using the certificate will prompt + # with a UI dialog asking for the certificate password, which we can't + # use in a headless CI environment + + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain +fi + +# Sign all files +PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" || return ; pwd -P ) +ENTITLEMENTS="$PARENT_PATH/EmbeddedEntitlements.entitlements" + +echo "Using entitlements file: $ENTITLEMENTS" + +# App +if [ "$1" == "*.app" ]; then + echo "[INFO] Signing app contents" + + find "$1/Contents/MacOS/"|while read fname; do + if [[ -f $fname ]]; then + echo "[INFO] Signing $fname" + codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname" + fi + done + + echo "[INFO] Signing app file" + + codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v +# Directory +elif [ -d "$1" ]; then + echo "[INFO] Signing directory contents" + + find "$1"|while read fname; do + if [[ -f $fname ]] && [[ ! $fname =~ /(*.(py|msg|enc))/ ]]; then + echo "[INFO] Signing $fname" + + codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname" + fi + done +# File +elif [ -f "$1" ]; then + echo "[INFO] Signing file" + + codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v +# Not matched +else + echo "[ERROR] Unknown file type" + exit 1 +fi diff --git a/Build/codesign_macos.sh b/Build/codesign_macos.sh new file mode 100755 index 00000000..5a05fc74 --- /dev/null +++ b/Build/codesign_macos.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +echo "Signing file: $1" + +# Setup keychain in CI +if [ -n "$CI" ]; then + # Turn our base64-encoded certificate back to a regular .p12 file + + echo "$MACOS_CERTIFICATE" | base64 --decode -o certificate.p12 + + # We need to create a new keychain, otherwise using the certificate will prompt + # with a UI dialog asking for the certificate password, which we can't + # use in a headless CI environment + + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain +fi + +# Sign all files +PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" || return ; pwd -P ) +ENTITLEMENTS="$PARENT_PATH/AppEntitlements.entitlements" + +echo "Using entitlements file: $ENTITLEMENTS" + +find "$1/Contents/MacOS/"|while read fname; do + if [[ -f $fname ]]; then + echo "[INFO] Signing $fname" + codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname" + fi +done + +echo "[INFO] Signing app file" + +codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v diff --git a/Build/notarize_macos.sh b/Build/notarize_macos.sh new file mode 100755 index 00000000..7fae9ccf --- /dev/null +++ b/Build/notarize_macos.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +echo "Notarizing file: $1" + +# Store the notarization credentials so that we can prevent a UI password dialog +# from blocking the CI + +echo "Create keychain profile" +xcrun notarytool store-credentials "notarytool-profile" \ +--apple-id "$MACOS_NOTARIZATION_APPLE_ID" \ +--team-id "$MACOS_NOTARIZATION_TEAM_ID" \ +--password "$MACOS_NOTARIZATION_PWD" + +# We can't notarize an app bundle directly, but we need to compress it as an archive. +# Therefore, we create a zip file containing our app bundle, so that we can send it to the +# notarization service + +echo "Creating temp notarization archive" +ditto -c -k --keepParent "$1" "notarization.zip" + +# Here we send the notarization request to the Apple's Notarization service, waiting for the result. +# This typically takes a few seconds inside a CI environment, but it might take more depending on the App +# characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if +# you're curious + +echo "Notarize app" +xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait + +# Finally, we need to "attach the staple" to our executable, which will allow our app to be +# validated by macOS even when an internet connection is not available. +echo "Attach staple" +xcrun stapler staple "$1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 903abd15..c7cdbeb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to Stability Matrix will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). +## v2.8.0-dev.4 +### Added +- Auto-update support for macOS +- New package installation flow +- Added `--use-directml` launch argument for SDWebUI DirectML fork +### Changed +- Changed default Period to "AllTime" in the Model Browser +### Fixed +- Fixed SDTurboScheduler's missing denoise parameter + +## v2.8.0-dev.3 +### Added +- Added release builds for macOS (Apple Silicon) +- Added new package: [OneTrainer](https://github.com/Nerogar/OneTrainer) +- Added ComfyUI launch argument configs: Cross Attention Method, Force Floating Point Precision, VAE Precision +- Added Delete button to the CivitAI Model Browser details dialog +- Added "Copy Link to Clipboard" for connected models in the Checkpoints page +### Changed +- Python Packages install dialog now allows entering multiple arguments or option flags +### Fixed +- Fixed environment variables grid not being editable related to [Avalonia #13843](https://github.com/AvaloniaUI/Avalonia/issues/13843) + +## v2.8.0-dev.2 +### Added +#### Inference +- Added Image to Video project type +#### Output Browser +- Added support for webp files +- Added "Send to Image to Image" and "Send to Image to Video" options to the context menu +### Changed +- Changed how settings file is written to disk to reduce potential data loss risk + +## v2.8.0-dev.1 +### Added +#### Inference +- Added image and model details in model selection boxes +- Added CLIP Skip setting, toggleable from the model settings button + ## v2.7.7 ### Added - Added `--use-directml` launch argument for SDWebUI DirectML fork diff --git a/README.md b/README.md index a5f348dd..27291185 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [release]: https://github.com/LykosAI/StabilityMatrix/releases/latest [download-win-x64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-win-x64.zip [download-linux-x64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-linux-x64.zip -[download-macos]: https://github.com/LykosAI/StabilityMatrix/issues/45 +[download-macos-arm64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-macos-arm64.dmg [auto1111]: https://github.com/AUTOMATIC1111/stable-diffusion-webui [comfy]: https://github.com/comfyanonymous/ComfyUI @@ -44,9 +44,7 @@ Multi-Platform Package Manager and Inference UI for Stable Diffusion [![Windows](https://img.shields.io/badge/Windows-%230079d5.svg?style=for-the-badge&logo=Windows%2011&logoColor=white)][download-win-x64] [![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)][download-linux-x64] -[![macOS](https://img.shields.io/badge/mac%20os%20%28apple%20silicon%29-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)][download-macos] - -> macOS builds are currently pending: [#45][download-macos] +[![macOS](https://img.shields.io/badge/mac%20os%20%28apple%20silicon%29-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)][download-macos-arm64] ### Inference - A reimagined built-in Stable Diffusion experience - Powerful auto-completion and syntax highlighting using a formal language grammar diff --git a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj index eb84e3f3..84e8c3f9 100644 --- a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj +++ b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj @@ -21,6 +21,7 @@ + diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index b3d39771..d3039d44 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -4,6 +4,7 @@ xmlns:local="using:StabilityMatrix.Avalonia" xmlns:idcr="using:Dock.Avalonia.Controls.Recycling" xmlns:styling="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia" + Name="Stability Matrix" RequestedThemeVariant="Dark"> @@ -23,6 +24,7 @@ + @@ -58,6 +60,8 @@ + + diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 742b10a3..d4bba201 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -80,7 +80,8 @@ public sealed class App : Application public static TopLevel TopLevel => TopLevel.GetTopLevel(VisualRoot)!; - internal static bool IsHeadlessMode => TopLevel.TryGetPlatformHandle()?.HandleDescriptor is null or "STUB"; + internal static bool IsHeadlessMode => + TopLevel.TryGetPlatformHandle()?.HandleDescriptor is null or "STUB"; [NotNull] public static IStorageProvider? StorageProvider { get; internal set; } @@ -117,8 +118,7 @@ public sealed class App : Application { // Remove DataAnnotations validation plugin since we're using INotifyDataErrorInfo from MvvmToolkit var dataValidationPluginsToRemove = BindingPlugins - .DataValidators - .OfType() + .DataValidators.OfType() .ToArray(); foreach (var plugin in dataValidationPluginsToRemove) @@ -161,22 +161,19 @@ public sealed class App : Application DesktopLifetime.MainWindow = setupWindow; - setupWindow - .ShowAsyncCts - .Token - .Register(() => + setupWindow.ShowAsyncCts.Token.Register(() => + { + if (setupWindow.Result == ContentDialogResult.Primary) + { + settingsManager.SetEulaAccepted(); + ShowMainWindow(); + DesktopLifetime.MainWindow.Show(); + } + else { - if (setupWindow.Result == ContentDialogResult.Primary) - { - settingsManager.SetEulaAccepted(); - ShowMainWindow(); - DesktopLifetime.MainWindow.Show(); - } - else - { - Shutdown(); - } - }); + Shutdown(); + } + }); } else { @@ -223,9 +220,6 @@ public sealed class App : Application mainWindow.WindowStartupLocation = WindowStartupLocation.CenterScreen; } - mainWindow.Closing += OnMainWindowClosing; - mainWindow.Closed += (_, _) => Shutdown(); - mainWindow.SetDefaultFonts(); VisualRoot = mainWindow; @@ -234,6 +228,7 @@ public sealed class App : Application DesktopLifetime.MainWindow = mainWindow; DesktopLifetime.Exit += OnExit; + DesktopLifetime.ShutdownRequested += OnShutdownRequested; } private static void ConfigureServiceProvider() @@ -276,7 +271,7 @@ public sealed class App : Application { provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService() @@ -297,7 +292,9 @@ public sealed class App : Application var serviceManager = new ServiceManager(); var serviceManagedTypes = exportedTypes - .Select(t => new { t, attributes = t.GetCustomAttributes(typeof(ManagedServiceAttribute), true) }) + .Select( + t => new { t, attributes = t.GetCustomAttributes(typeof(ManagedServiceAttribute), true) } + ) .Where(t1 => t1.attributes is { Length: > 0 }) .Select(t1 => t1.t) .ToList(); @@ -322,8 +319,7 @@ public sealed class App : Application services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix"); var exportedTypes = AppDomain - .CurrentDomain - .GetAssemblies() + .CurrentDomain.GetAssemblies() .Where(a => a.FullName?.StartsWith("StabilityMatrix") == true) .SelectMany(a => a.GetExportedTypes()) .ToArray(); @@ -332,7 +328,8 @@ public sealed class App : Application .Select(t => new { t, attributes = t.GetCustomAttributes(typeof(TransientAttribute), false) }) .Where( t1 => - t1.attributes is { Length: > 0 } && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) + t1.attributes is { Length: > 0 } + && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) ) .Select(t1 => new { Type = t1.t, Attribute = (TransientAttribute)t1.attributes[0] }); @@ -352,9 +349,12 @@ public sealed class App : Application .Select(t => new { t, attributes = t.GetCustomAttributes(typeof(SingletonAttribute), false) }) .Where( t1 => - t1.attributes is { Length: > 0 } && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) + t1.attributes is { Length: > 0 } + && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) ) - .Select(t1 => new { Type = t1.t, Attributes = t1.attributes.Cast().ToArray() }); + .Select( + t1 => new { Type = t1.t, Attributes = t1.attributes.Cast().ToArray() } + ); foreach (var typePair in singletonTypes) { @@ -386,7 +386,9 @@ public sealed class App : Application // Rich presence services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton( + provider => provider.GetRequiredService() + ); Config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) @@ -557,7 +559,11 @@ public sealed class App : Application #if DEBUG builder.AddNLog( ConfigureLogging(), - new NLogProviderOptions { IgnoreEmptyEventId = false, CaptureEventId = EventIdCaptureType.Legacy } + new NLogProviderOptions + { + IgnoreEmptyEventId = false, + CaptureEventId = EventIdCaptureType.Legacy + } ); #else builder.AddNLog(ConfigureLogging()); @@ -577,91 +583,79 @@ public sealed class App : Application { if (Current is null) throw new NullReferenceException("Current Application was null when Shutdown called"); + if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) { - lifetime.Shutdown(exitCode); + try + { + var result = lifetime.TryShutdown(exitCode); + Debug.WriteLine($"Shutdown: {result}"); + + if (result) + { + Environment.Exit(exitCode); + } + } + catch (InvalidOperationException) + { + // Ignore in case already shutting down + } + } + else + { + Environment.Exit(exitCode); } } - /// - /// Handle shutdown requests (happens before ) - /// - private static void OnMainWindowClosing(object? sender, WindowClosingEventArgs e) + private static void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e) { - if (e.Cancel) - return; - - var mainWindow = (MainWindow)sender!; - - // Show confirmation if package running - var launchPageViewModel = Services.GetRequiredService(); - launchPageViewModel.OnMainWindowClosing(e); + Debug.WriteLine("Start OnShutdownRequested"); if (e.Cancel) return; // Check if we need to dispose IAsyncDisposables if ( - !isAsyncDisposeComplete - && Services.GetServices().ToList() is { Count: > 0 } asyncDisposables + isAsyncDisposeComplete + || Services.GetServices().ToList() is not { Count: > 0 } asyncDisposables ) - { - // Cancel shutdown for now - e.Cancel = true; - isAsyncDisposeComplete = true; + return; - Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables"); + // Cancel shutdown for now + e.Cancel = true; + isAsyncDisposeComplete = true; - Task.Run(async () => + Debug.WriteLine("OnShutdownRequested Canceled: Disposing IAsyncDisposables"); + + Dispatcher + .UIThread.InvokeAsync(async () => + { + foreach (var disposable in asyncDisposables) { - foreach (var disposable in asyncDisposables) + Debug.WriteLine($"Disposing IAsyncDisposable ({disposable.GetType().Name})"); + try + { + await disposable.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) { - Debug.WriteLine($"Disposing IAsyncDisposable ({disposable.GetType().Name})"); - try - { - await disposable.DisposeAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Debug.Fail(ex.ToString()); - } + Debug.Fail(ex.ToString()); } - }) - .ContinueWith(_ => + } + }) + .ContinueWith(_ => + { + // Shutdown again + Debug.WriteLine("Finished disposing IAsyncDisposables, shutting down"); + + if (Dispatcher.UIThread.SupportsRunLoops) { - // Shutdown again Dispatcher.UIThread.Invoke(() => Shutdown()); - }) - .SafeFireAndForget(); - - return; - } - - OnMainWindowClosingTerminal(mainWindow); - } - - /// - /// Called at the end of before the main window is closed. - /// - private static void OnMainWindowClosingTerminal(Window sender) - { - var settingsManager = Services.GetRequiredService(); - - // Save window position - var validWindowPosition = sender.Screens.All.Any(screen => screen.Bounds.Contains(sender.Position)); + } - settingsManager.Transaction( - s => - { - s.WindowSettings = new WindowSettings( - sender.Width, - sender.Height, - validWindowPosition ? sender.Position.X : 0, - validWindowPosition ? sender.Position.Y : 0 - ); - }, - ignoreMissingLibraryDir: true - ); + Environment.Exit(0); + }) + .SafeFireAndForget(); } private static void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args) @@ -714,10 +708,12 @@ public sealed class App : Application .WriteTo( new FileTarget { - Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", + Layout = + "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", ArchiveOldFileOnStartup = true, FileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.log", - ArchiveFileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.{#}.log", + ArchiveFileName = + "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.{#}.log", ArchiveNumbering = ArchiveNumberingMode.Rolling, MaxArchiveFiles = 2 } @@ -730,7 +726,9 @@ public sealed class App : Application builder.ForLogger("Microsoft.Extensions.Http.*").WriteToNil(NLog.LogLevel.Warn); // Disable console trace logging by default - builder.ForLogger("StabilityMatrix.Avalonia.ViewModels.ConsoleViewModel").WriteToNil(NLog.LogLevel.Debug); + builder + .ForLogger("StabilityMatrix.Avalonia.ViewModels.ConsoleViewModel") + .WriteToNil(NLog.LogLevel.Debug); // Disable LoadableViewModelBase trace logging by default builder @@ -751,20 +749,18 @@ public sealed class App : Application // Sentry if (SentrySdk.IsEnabled) { - LogManager - .Configuration - .AddSentry(o => - { - o.InitializeSdk = false; - o.Layout = "${message}"; - o.ShutdownTimeoutSeconds = 5; - o.IncludeEventDataOnBreadcrumbs = true; - o.BreadcrumbLayout = "${logger}: ${message}"; - // Debug and higher are stored as breadcrumbs (default is Info) - o.MinimumBreadcrumbLevel = NLog.LogLevel.Debug; - // Error and higher is sent as event (default is Error) - o.MinimumEventLevel = NLog.LogLevel.Error; - }); + LogManager.Configuration.AddSentry(o => + { + o.InitializeSdk = false; + o.Layout = "${message}"; + o.ShutdownTimeoutSeconds = 5; + o.IncludeEventDataOnBreadcrumbs = true; + o.BreadcrumbLayout = "${logger}: ${message}"; + // Debug and higher are stored as breadcrumbs (default is Info) + o.MinimumBreadcrumbLevel = NLog.LogLevel.Debug; + // Error and higher is sent as event (default is Error) + o.MinimumEventLevel = NLog.LogLevel.Error; + }); } LogManager.ReconfigExistingLoggers(); @@ -803,34 +799,36 @@ public sealed class App : Application results.Add(ms); } - Dispatcher - .UIThread - .InvokeAsync(async () => - { - var dest = await StorageProvider.SaveFilePickerAsync( - new FilePickerSaveOptions() { SuggestedFileName = "screenshot.png", ShowOverwritePrompt = true } - ); + Dispatcher.UIThread.InvokeAsync(async () => + { + var dest = await StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions() + { + SuggestedFileName = "screenshot.png", + ShowOverwritePrompt = true + } + ); - if (dest?.TryGetLocalPath() is { } localPath) + if (dest?.TryGetLocalPath() is { } localPath) + { + var localFile = new FilePath(localPath); + foreach (var (i, stream) in results.Enumerate()) { - var localFile = new FilePath(localPath); - foreach (var (i, stream) in results.Enumerate()) + var name = localFile.NameWithoutExtension; + if (results.Count > 1) { - var name = localFile.NameWithoutExtension; - if (results.Count > 1) - { - name += $"_{i + 1}"; - } - - localFile = localFile.Directory!.JoinFile(name + ".png"); - localFile.Create(); - - await using var fileStream = localFile.Info.OpenWrite(); - stream.Seek(0, SeekOrigin.Begin); - await stream.CopyToAsync(fileStream); + name += $"_{i + 1}"; } + + localFile = localFile.Directory!.JoinFile(name + ".png"); + localFile.Create(); + + await using var fileStream = localFile.Info.OpenWrite(); + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(fileStream); } - }); + } + }); } [Conditional("DEBUG")] diff --git a/StabilityMatrix.Avalonia/Assets.cs b/StabilityMatrix.Avalonia/Assets.cs index 68f76884..5a51640e 100644 --- a/StabilityMatrix.Avalonia/Assets.cs +++ b/StabilityMatrix.Avalonia/Assets.cs @@ -11,16 +11,19 @@ namespace StabilityMatrix.Avalonia; internal static class Assets { - public static AvaloniaResource AppIcon { get; } = new("avares://StabilityMatrix.Avalonia/Assets/Icon.ico"); + public static AvaloniaResource AppIcon { get; } = + new("avares://StabilityMatrix.Avalonia/Assets/Icon.ico"); - public static AvaloniaResource AppIconPng { get; } = new("avares://StabilityMatrix.Avalonia/Assets/Icon.png"); + public static AvaloniaResource AppIconPng { get; } = + new("avares://StabilityMatrix.Avalonia/Assets/Icon.png"); /// /// Fixed image for models with no images. /// public static Uri NoImage { get; } = new("avares://StabilityMatrix.Avalonia/Assets/noimage.png"); - public static AvaloniaResource LicensesJson => new("avares://StabilityMatrix.Avalonia/Assets/licenses.json"); + public static AvaloniaResource LicensesJson => + new("avares://StabilityMatrix.Avalonia/Assets/licenses.json"); public static AvaloniaResource ImagePromptLanguageJson => new("avares://StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json"); @@ -28,7 +31,8 @@ internal static class Assets public static AvaloniaResource ThemeMatrixDarkJson => new("avares://StabilityMatrix.Avalonia/Assets/ThemeMatrixDark.json"); - public static AvaloniaResource HfPackagesJson => new("avares://StabilityMatrix.Avalonia/Assets/hf-packages.json"); + public static AvaloniaResource HfPackagesJson => + new("avares://StabilityMatrix.Avalonia/Assets/hf-packages.json"); private const UnixFileMode Unix755 = UnixFileMode.UserRead @@ -44,7 +48,10 @@ internal static class Assets [SupportedOSPlatform("macos")] public static AvaloniaResource SevenZipExecutable => Compat.Switch( - (PlatformKind.Windows, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za.exe")), + ( + PlatformKind.Windows, + new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za.exe") + ), ( PlatformKind.Linux | PlatformKind.X64, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs", Unix755) @@ -112,10 +119,9 @@ internal static class Assets PlatformKind.MacOS | PlatformKind.Arm, new RemoteResource { - Url = new Uri( - "https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-aarch64-apple-darwin-install_only.tar.gz" - ), - HashSha256 = "8348bc3c2311f94ec63751fb71bd0108174be1c4def002773cf519ee1506f96f" + // Requires our distribution with signed dylib for gatekeeper + Url = new Uri("https://cdn.lykos.ai/cpython-3.10.11-macos-arm64.zip"), + HashSha256 = "83c00486e0af9c460604a425e519d58e4b9604fbe7a4448efda0f648f86fb6e3" } ) ); @@ -148,7 +154,9 @@ internal static class Assets /// /// Yield AvaloniaResources given a relative directory path within the 'Assets' folder. /// - public static IEnumerable<(AvaloniaResource resource, string relativePath)> FindAssets(string relativeAssetPath) + public static IEnumerable<(AvaloniaResource resource, string relativePath)> FindAssets( + string relativeAssetPath + ) { var baseUri = new Uri("avares://StabilityMatrix.Avalonia/Assets/"); var targetUri = new Uri(baseUri, relativeAssetPath); diff --git a/StabilityMatrix.Avalonia/Assets/AppIcon.icns b/StabilityMatrix.Avalonia/Assets/AppIcon.icns new file mode 100644 index 00000000..079c0b1f Binary files /dev/null and b/StabilityMatrix.Avalonia/Assets/AppIcon.icns differ diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml index eb681d53..73b43f25 100644 --- a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml +++ b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml @@ -8,30 +8,43 @@ xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:gif="clr-namespace:Avalonia.Gif;assembly=Avalonia.Gif" d:DataContext="{x:Static mocks:DesignData.SampleImageSource}" d:DesignHeight="450" d:DesignWidth="800" x:DataType="models:ImageSource" mc:Ignorable="d"> - - - - - - - + + + + + + + + + + + + + + + + + + + ("CopyMenuItem")!; - copyMenuItem.Command = new AsyncRelayCommand(FlyoutCopy); + + if (this.FindControl("CopyMenuItem") is { } copyMenuItem) + { + copyMenuItem.Command = new AsyncRelayCommand(FlyoutCopy); + } } - + private static async Task FlyoutCopy(Bitmap? image) { - if (image is null || !Compat.IsWindows) return; + if (image is null || !Compat.IsWindows) + return; await Task.Run(() => { diff --git a/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs b/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs new file mode 100644 index 00000000..7209879c --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs @@ -0,0 +1,41 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; + +namespace StabilityMatrix.Avalonia.Controls; + +public class BetterComboBox : ComboBox +{ + // protected override Type StyleKeyOverride { get; } = typeof(CheckBox); + + public static readonly DirectProperty SelectionBoxItemTemplateProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectionBoxItemTemplate), + v => v.SelectionBoxItemTemplate, + (x, v) => x.SelectionBoxItemTemplate = v + ); + + private IDataTemplate? _selectionBoxItemTemplate; + + public IDataTemplate? SelectionBoxItemTemplate + { + get => _selectionBoxItemTemplate; + private set => SetAndRaise(SelectionBoxItemTemplateProperty, ref _selectionBoxItemTemplate, value); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (e.NameScope.Find("ContentPresenter") is { } contentPresenter) + { + if (SelectionBoxItemTemplate is { } template) + { + contentPresenter.ContentTemplate = template; + } + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs b/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs new file mode 100644 index 00000000..4e9ac24f --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DataTemplateSelector.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Metadata; +using JetBrains.Annotations; +using StabilityMatrix.Avalonia.Models; + +namespace StabilityMatrix.Avalonia.Controls; + +/// +/// Selector for objects implementing +/// +[PublicAPI] +public class DataTemplateSelector : IDataTemplate + where TKey : notnull +{ + /// + /// Key that is used when no other key matches + /// + public TKey? DefaultKey { get; set; } + + [Content] + public Dictionary Templates { get; } = new(); + + public bool Match(object? data) => data is ITemplateKey; + + /// + public Control Build(object? data) + { + if (data is not ITemplateKey key) + throw new ArgumentException(null, nameof(data)); + + if (Templates.TryGetValue(key.TemplateKey, out var template)) + { + return template.Build(data)!; + } + + if (DefaultKey is not null && Templates.TryGetValue(DefaultKey, out var defaultTemplate)) + { + return defaultTemplate.Build(data)!; + } + + throw new ArgumentException(null, nameof(data)); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml index e0fc6a1c..a8b5c941 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/ModelCard.axaml @@ -7,11 +7,14 @@ xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" + xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" x:DataType="inference:ModelCardViewModel"> + @@ -21,126 +24,39 @@ - + - - - - + SelectedItem="{Binding SelectedModel}"/> @@ -149,30 +65,28 @@ - - + SelectedItem="{Binding SelectedRefiner}"/> + + + + + + - + diff --git a/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml new file mode 100644 index 00000000..f9f38ba9 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml @@ -0,0 +1,122 @@ + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs new file mode 100644 index 00000000..70347512 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VideoGenerationSettingsCard.axaml.cs @@ -0,0 +1,7 @@ +using Avalonia.Controls.Primitives; +using StabilityMatrix.Core.Attributes; + +namespace StabilityMatrix.Avalonia.Controls; + +[Transient] +public class VideoGenerationSettingsCard : TemplatedControl { } diff --git a/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml new file mode 100644 index 00000000..c247611a --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml @@ -0,0 +1,98 @@ + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs new file mode 100644 index 00000000..a730a993 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VideoOutputSettingsCard.axaml.cs @@ -0,0 +1,7 @@ +using Avalonia.Controls.Primitives; +using StabilityMatrix.Core.Attributes; + +namespace StabilityMatrix.Avalonia.Controls; + +[Transient] +public class VideoOutputSettingsCard : TemplatedControl { } diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index a1bd2022..9688f93d 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -4,15 +4,14 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using AvaloniaEdit.Utils; -using CommunityToolkit.Mvvm.ComponentModel; using DynamicData; using DynamicData.Binding; using Microsoft.Extensions.DependencyInjection; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Semver; using StabilityMatrix.Avalonia.Controls.CodeCompletion; using StabilityMatrix.Avalonia.Models; @@ -24,8 +23,9 @@ using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Inference; -using StabilityMatrix.Avalonia.ViewModels.Inference.Modules; +using StabilityMatrix.Avalonia.ViewModels.Inference.Video; using StabilityMatrix.Avalonia.ViewModels.OutputsPage; +using StabilityMatrix.Avalonia.ViewModels.PackageManager; using StabilityMatrix.Avalonia.ViewModels.Progress; using StabilityMatrix.Avalonia.ViewModels.Settings; using StabilityMatrix.Core.Api; @@ -175,12 +175,23 @@ public static class DesignData }; LaunchOptionsViewModel.UpdateFilterCards(); - InstallerViewModel = Services.GetRequiredService(); - InstallerViewModel.AvailablePackages = new ObservableCollectionExtended( - packageFactory.GetAllAvailablePackages() + NewInstallerDialogViewModel = Services.GetRequiredService(); + // NewInstallerDialogViewModel.InferencePackages = new ObservableCollectionExtended( + // packageFactory.GetPackagesByType(PackageType.SdInference).OrderBy(p => p.InstallerSortOrder) + // ); + // NewInstallerDialogViewModel.TrainingPackages = new ObservableCollection( + // packageFactory.GetPackagesByType(PackageType.SdTraining).OrderBy(p => p.InstallerSortOrder) + // ); + + PackageInstallDetailViewModel = new PackageInstallDetailViewModel( + packageFactory.GetAllAvailablePackages().FirstOrDefault() as BaseGitPackage, + settingsManager, + notificationService, + null, + null, + null, + null ); - InstallerViewModel.SelectedPackage = InstallerViewModel.AvailablePackages[0]; - InstallerViewModel.ReleaseNotes = "## Release Notes\nThis is a test release note."; ObservableCacheEx.AddOrUpdate( CheckpointsPageViewModel.CheckpointFoldersCache, @@ -373,25 +384,27 @@ public static class DesignData new() { FilePath = "~/Models/Lora/model.safetensors", Title = "Some model" } }; - ProgressManagerViewModel - .ProgressItems - .AddRange( - new ProgressItemViewModelBase[] - { - new ProgressItemViewModel( - new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressReport(0.5f, "Downloading...")) - ), - new MockDownloadProgressItemViewModel( - "Very Long Test File Name Need Even More Longness Thanks That's pRobably good 2.exe" - ), - new PackageInstallProgressItemViewModel( - new PackageModificationRunner - { - CurrentProgress = new ProgressReport(0.5f, "Installing package...") - } + ProgressManagerViewModel.ProgressItems.AddRange( + new ProgressItemViewModelBase[] + { + new ProgressItemViewModel( + new ProgressItem( + Guid.NewGuid(), + "Test File.exe", + new ProgressReport(0.5f, "Downloading...") ) - } - ); + ), + new MockDownloadProgressItemViewModel( + "Very Long Test File Name Need Even More Longness Thanks That's pRobably good 2.exe" + ), + new PackageInstallProgressItemViewModel( + new PackageModificationRunner + { + CurrentProgress = new ProgressReport(0.5f, "Installing package...") + } + ) + } + ); UpdateViewModel = Services.GetRequiredService(); UpdateViewModel.CurrentVersionText = "v2.0.0"; @@ -402,7 +415,10 @@ public static class DesignData } [NotNull] - public static InstallerViewModel? InstallerViewModel { get; private set; } + public static PackageInstallBrowserViewModel? NewInstallerDialogViewModel { get; private set; } + + [NotNull] + public static PackageInstallDetailViewModel? PackageInstallDetailViewModel { get; private set; } [NotNull] public static LaunchOptionsViewModel? LaunchOptionsViewModel { get; private set; } @@ -413,12 +429,14 @@ public static class DesignData public static ServiceManager DialogFactory => Services.GetRequiredService>(); - public static MainWindowViewModel MainWindowViewModel => Services.GetRequiredService(); + public static MainWindowViewModel MainWindowViewModel => + Services.GetRequiredService(); public static FirstLaunchSetupViewModel FirstLaunchSetupViewModel => Services.GetRequiredService(); - public static LaunchPageViewModel LaunchPageViewModel => Services.GetRequiredService(); + public static LaunchPageViewModel LaunchPageViewModel => + Services.GetRequiredService(); public static HuggingFacePageViewModel HuggingFacePageViewModel => Services.GetRequiredService(); @@ -452,7 +470,10 @@ public static class DesignData vm.SetPackages(settings.Settings.InstalledPackages); vm.SetUnknownPackages( - new InstalledPackage[] { UnknownInstalledPackage.FromDirectoryName("sd-unknown-with-long-name"), } + new InstalledPackage[] + { + UnknownInstalledPackage.FromDirectoryName("sd-unknown-with-long-name"), + } ); vm.PackageCards[0].IsUpdateAvailable = true; @@ -469,10 +490,14 @@ public static class DesignData public static SettingsViewModel SettingsViewModel => Services.GetRequiredService(); + public static NewPackageManagerViewModel NewPackageManagerViewModel => + Services.GetRequiredService(); + public static InferenceSettingsViewModel InferenceSettingsViewModel => Services.GetRequiredService(); - public static MainSettingsViewModel MainSettingsViewModel => Services.GetRequiredService(); + public static MainSettingsViewModel MainSettingsViewModel => + Services.GetRequiredService(); public static AccountSettingsViewModel AccountSettingsViewModel => Services.GetRequiredService(); @@ -556,7 +581,10 @@ The gallery images are often inpainted, but you will get something very similar } } }; - var sampleViewModel = new ModelVersionViewModel(new HashSet { "ABCD" }, sampleCivitVersions[0]); + var sampleViewModel = new ModelVersionViewModel( + new HashSet { "ABCD" }, + sampleCivitVersions[0] + ); // Sample data for dialogs vm.Versions = new List { sampleViewModel }; @@ -630,13 +658,22 @@ The gallery images are often inpainted, but you will get something very similar vm.OutputProgress.Text = "Sampler 10/30"; }); + public static InferenceImageToVideoViewModel InferenceImageToVideoViewModel => + DialogFactory.Get(vm => + { + vm.OutputProgress.Value = 10; + vm.OutputProgress.Maximum = 30; + vm.OutputProgress.Text = "Sampler 10/30"; + }); + public static InferenceImageToImageViewModel InferenceImageToImageViewModel => DialogFactory.Get(); public static InferenceImageUpscaleViewModel InferenceImageUpscaleViewModel => DialogFactory.Get(); - public static PackageImportViewModel PackageImportViewModel => DialogFactory.Get(); + public static PackageImportViewModel PackageImportViewModel => + DialogFactory.Get(); public static RefreshBadgeViewModel RefreshBadgeViewModel => new() { State = ProgressState.Success }; @@ -652,6 +689,8 @@ The gallery images are often inpainted, but you will get something very similar }); public static SeedCardViewModel SeedCardViewModel => new(); + public static SvdImgToVidConditioningViewModel SvdImgToVidConditioningViewModel => new(); + public static VideoOutputSettingsCardViewModel VideoOutputSettingsCardViewModel => new(); public static SamplerCardViewModel SamplerCardViewModel => DialogFactory.Get(vm => @@ -681,6 +720,9 @@ The gallery images are often inpainted, but you will get something very similar public static ModelCardViewModel ModelCardViewModel => DialogFactory.Get(); + public static ImgToVidModelCardViewModel ImgToVidModelCardViewModel => + DialogFactory.Get(); + public static ImageGalleryCardViewModel ImageGalleryCardViewModel => DialogFactory.Get(vm => { @@ -706,7 +748,8 @@ The gallery images are often inpainted, but you will get something very similar ); }); - public static ImageFolderCardViewModel ImageFolderCardViewModel => DialogFactory.Get(); + public static ImageFolderCardViewModel ImageFolderCardViewModel => + DialogFactory.Get(); public static FreeUCardViewModel FreeUCardViewModel => DialogFactory.Get(); @@ -759,7 +802,8 @@ The gallery images are often inpainted, but you will get something very similar public static UpscalerCardViewModel UpscalerCardViewModel => DialogFactory.Get(); - public static BatchSizeCardViewModel BatchSizeCardViewModel => DialogFactory.Get(); + public static BatchSizeCardViewModel BatchSizeCardViewModel => + DialogFactory.Get(); public static BatchSizeCardViewModel BatchSizeCardViewModelWithIndexOption => DialogFactory.Get(vm => @@ -790,6 +834,42 @@ The gallery images are often inpainted, but you will get something very similar } } + public static IEnumerable SampleHybridModels { get; } = + [ + HybridModelFile.FromLocal( + new LocalModelFile + { + SharedFolderType = SharedFolderType.StableDiffusion, + RelativePath = "art_shaper_v8.safetensors", + PreviewImageFullPath = + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/dd9b038c-bd15-43ab-86ab-66e145ad7ff2/width=512", + ConnectedModelInfo = new ConnectedModelInfo + { + ModelName = "Art Shaper (very long name example)", + VersionName = "Style v8 (very long name)" + } + } + ), + HybridModelFile.FromLocal( + new LocalModelFile + { + SharedFolderType = SharedFolderType.StableDiffusion, + RelativePath = "background_arts.safetensors", + PreviewImageFullPath = + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/71c81ddf-d8c3-46b4-843d-9f8f20a9254a/width=512", + ConnectedModelInfo = new ConnectedModelInfo + { + ModelName = "Background Arts", + VersionName = "Anime Style v10" + } + } + ), + HybridModelFile.FromRemote("v1-5-pruned-emaonly.safetensors"), + HybridModelFile.FromRemote("sample-model.pt"), + ]; + + public static HybridModelFile SampleHybridModel => SampleHybridModels.First(); + public static ImageViewerViewModel ImageViewerViewModel => DialogFactory.Get(vm => { @@ -816,7 +896,8 @@ The gallery images are often inpainted, but you will get something very similar public static InferenceConnectionHelpViewModel InferenceConnectionHelpViewModel => DialogFactory.Get(); - public static SelectImageCardViewModel SelectImageCardViewModel => DialogFactory.Get(); + public static SelectImageCardViewModel SelectImageCardViewModel => + DialogFactory.Get(); public static SelectImageCardViewModel SelectImageCardViewModel_WithImage => DialogFactory.Get(vm => @@ -829,12 +910,17 @@ The gallery images are often inpainted, but you will get something very similar }); public static ImageSource SampleImageSource => - new(new Uri("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1500")) + new( + new Uri( + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1500" + ) + ) { Label = "Test Image" }; - public static ControlNetCardViewModel ControlNetCardViewModel => DialogFactory.Get(); + public static ControlNetCardViewModel ControlNetCardViewModel => + DialogFactory.Get(); public static Indexer Types { get; } = new(); @@ -844,7 +930,8 @@ The gallery images are often inpainted, but you will get something very similar { get { - var type = Type.GetType(typeName) ?? throw new ArgumentException($"Type {typeName} not found"); + var type = + Type.GetType(typeName) ?? throw new ArgumentException($"Type {typeName} not found"); try { return Services.GetService(type); diff --git a/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs b/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs index 3081441c..92bfc564 100644 --- a/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs +++ b/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Database; @@ -25,9 +26,21 @@ public class MockModelIndexService : IModelIndexService } /// - public Task> GetModelsOfType(SharedFolderType type) + public Task> FindAsync(SharedFolderType type) { - return Task.FromResult>(new List()); + return Task.FromResult(Enumerable.Empty()); + } + + /// + public Task> FindByHashAsync(string hashBlake3) + { + return Task.FromResult(Enumerable.Empty()); + } + + /// + public Task RemoveModelAsync(LocalModelFile model) + { + return Task.FromResult(false); } /// diff --git a/StabilityMatrix.Avalonia/DialogHelper.cs b/StabilityMatrix.Avalonia/DialogHelper.cs index 0cdfeabb..7ad27bcb 100644 --- a/StabilityMatrix.Avalonia/DialogHelper.cs +++ b/StabilityMatrix.Avalonia/DialogHelper.cs @@ -23,15 +23,15 @@ using NLog; using Refit; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Helpers; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Services; using TextMateSharp.Grammars; using Process = FuzzySharp.Process; -using StabilityMatrix.Avalonia.Languages; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Core.Helper; namespace StabilityMatrix.Avalonia; @@ -48,11 +48,7 @@ public static class DialogHelper IReadOnlyList textFields ) { - return CreateTextEntryDialog( - title, - new MarkdownScrollViewer { Markdown = description }, - textFields - ); + return CreateTextEntryDialog(title, new MarkdownScrollViewer { Markdown = description }, textFields); } /// @@ -80,11 +76,7 @@ public static class DialogHelper var grid = new Grid { - RowDefinitions = - { - new RowDefinition(GridLength.Star), - new RowDefinition(GridLength.Auto) - }, + RowDefinitions = { new RowDefinition(GridLength.Star), new RowDefinition(GridLength.Auto) }, Children = { markdown, image } }; @@ -109,18 +101,14 @@ public static class DialogHelper var grid = new Grid { - RowDefinitions = - { - new RowDefinition(GridLength.Auto), - new RowDefinition(GridLength.Star) - }, + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star) }, Children = { content, stackPanel } }; grid.Loaded += (_, _) => { // Focus first TextBox - var firstTextBox = stackPanel.Children - .OfType() + var firstTextBox = stackPanel + .Children.OfType() .FirstOrDefault() .FindDescendantOfType(); firstTextBox!.Focus(); @@ -254,16 +242,16 @@ public static class DialogHelper Content = viewer, CloseButtonText = Resources.Action_Close, IsPrimaryButtonEnabled = false, + MinDialogWidth = 800, + MaxDialogHeight = 1000, + MaxDialogWidth = 1000 }; } /// /// Create a dialog for displaying an ApiException /// - public static BetterContentDialog CreateApiExceptionDialog( - ApiException exception, - string? title = null - ) + public static BetterContentDialog CreateApiExceptionDialog(ApiException exception, string? title = null) { Dispatcher.UIThread.VerifyAccess(); @@ -275,9 +263,7 @@ public static class DialogHelper Options = { ShowColumnRulers = false, AllowScrollBelowDocument = false } }; var registryOptions = new RegistryOptions(ThemeName.DarkPlus); - textEditor - .InstallTextMate(registryOptions) - .SetGrammar(registryOptions.GetScopeByLanguageId("json")); + textEditor.InstallTextMate(registryOptions).SetGrammar(registryOptions.GetScopeByLanguageId("json")); var mainGrid = new StackPanel { @@ -354,9 +340,7 @@ public static class DialogHelper Options = { ShowColumnRulers = false, AllowScrollBelowDocument = false } }; var registryOptions = new RegistryOptions(ThemeName.DarkPlus); - textEditor - .InstallTextMate(registryOptions) - .SetGrammar(registryOptions.GetScopeByLanguageId("json")); + textEditor.InstallTextMate(registryOptions).SetGrammar(registryOptions.GetScopeByLanguageId("json")); var mainGrid = new StackPanel { @@ -437,8 +421,7 @@ public static class DialogHelper { Dispatcher.UIThread.VerifyAccess(); - var title = - exception is PromptSyntaxError ? "Prompt Syntax Error" : "Prompt Validation Error"; + var title = exception is PromptSyntaxError ? "Prompt Syntax Error" : "Prompt Validation Error"; // Get the index of the error var errorIndex = exception.TextOffset; diff --git a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs b/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs index 38c28fb3..a4a88705 100644 --- a/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs +++ b/StabilityMatrix.Avalonia/Extensions/ComfyNodeBuilderExtensions.cs @@ -20,15 +20,17 @@ public static class ComfyNodeBuilderExtensions int? batchIndex = null ) { - var emptyLatent = builder.Nodes.AddTypedNode( - new ComfyNodeBuilder.EmptyLatentImage - { - Name = "EmptyLatentImage", - BatchSize = batchSize, - Height = height, - Width = width - } - ); + var emptyLatent = builder + .Nodes + .AddTypedNode( + new ComfyNodeBuilder.EmptyLatentImage + { + Name = "EmptyLatentImage", + BatchSize = batchSize, + Height = height, + Width = width + } + ); builder.Connections.Primary = emptyLatent.Output; builder.Connections.PrimarySize = new Size(width, height); @@ -36,7 +38,8 @@ public static class ComfyNodeBuilderExtensions // If batch index is selected, add a LatentFromBatch if (batchIndex is not null) { - builder.Connections.Primary = builder.Nodes + builder.Connections.Primary = builder + .Nodes .AddNamedNode( ComfyNodeBuilder.LatentFromBatch( "LatentFromBatch", @@ -64,9 +67,9 @@ public static class ComfyNodeBuilderExtensions var sourceImageRelativePath = Path.Combine("Inference", image.GetHashGuidFileNameCached()); // Load source - var loadImage = builder.Nodes.AddTypedNode( - new ComfyNodeBuilder.LoadImage { Name = "LoadImage", Image = sourceImageRelativePath } - ); + var loadImage = builder + .Nodes + .AddTypedNode(new ComfyNodeBuilder.LoadImage { Name = "LoadImage", Image = sourceImageRelativePath }); builder.Connections.Primary = loadImage.Output1; builder.Connections.PrimarySize = imageSize; @@ -74,7 +77,8 @@ public static class ComfyNodeBuilderExtensions // If batch index is selected, add a LatentFromBatch if (batchIndex is not null) { - builder.Connections.Primary = builder.Nodes + builder.Connections.Primary = builder + .Nodes .AddNamedNode( ComfyNodeBuilder.LatentFromBatch( "LatentFromBatch", @@ -93,24 +97,25 @@ public static class ComfyNodeBuilderExtensions if (builder.Connections.Primary is null) throw new ArgumentException("No Primary"); - var image = builder.Connections.Primary.Match( - _ => - builder.GetPrimaryAsImage( - builder.Connections.PrimaryVAE - ?? builder.Connections.RefinerVAE - ?? builder.Connections.BaseVAE - ?? throw new ArgumentException("No Primary, Refiner, or Base VAE") - ), - image => image - ); + var image = builder + .Connections + .Primary + .Match( + _ => + builder.GetPrimaryAsImage( + builder.Connections.PrimaryVAE + ?? builder.Connections.Refiner.VAE + ?? builder.Connections.Base.VAE + ?? throw new ArgumentException("No Primary, Refiner, or Base VAE") + ), + image => image + ); - var previewImage = builder.Nodes.AddTypedNode( - new ComfyNodeBuilder.PreviewImage - { - Name = builder.Nodes.GetUniqueName("SaveImage"), - Images = image - } - ); + var previewImage = builder + .Nodes + .AddTypedNode( + new ComfyNodeBuilder.PreviewImage { Name = builder.Nodes.GetUniqueName("SaveImage"), Images = image } + ); builder.Connections.OutputNodes.Add(previewImage); diff --git a/StabilityMatrix.Avalonia/Helpers/UriHandler.cs b/StabilityMatrix.Avalonia/Helpers/UriHandler.cs index 75dd4a5f..3b16d078 100644 --- a/StabilityMatrix.Avalonia/Helpers/UriHandler.cs +++ b/StabilityMatrix.Avalonia/Helpers/UriHandler.cs @@ -58,17 +58,19 @@ public class UriHandler public void RegisterUriScheme() { + // Not supported on macos + if (Compat.IsWindows) { RegisterUriSchemeWin(); } - else + else if (Compat.IsLinux) { - // Try to register on unix but ignore errors + // Try to register on linux but ignore errors // Library does not support some distros try { - RegisterUriSchemeUnix(); + RegisterUriSchemeLinux(); } catch (Exception e) { @@ -98,9 +100,14 @@ public class UriHandler } } - private void RegisterUriSchemeUnix() + [SupportedOSPlatform("linux")] + private void RegisterUriSchemeLinux() { - var service = URISchemeServiceFactory.GetURISchemeSerivce(Scheme, Description, Compat.AppCurrentPath.FullPath); + var service = URISchemeServiceFactory.GetURISchemeSerivce( + Scheme, + Description, + Compat.AppCurrentPath.FullPath + ); service.Set(); } } diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 4347bb9b..df668ab0 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -707,6 +707,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Augmentation Level. + /// + public static string Label_AugmentationLevel { + get { + return ResourceManager.GetString("Label_AugmentationLevel", resourceCulture); + } + } + /// /// Looks up a localized string similar to Auto Completion. /// @@ -833,6 +842,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to CLIP Skip. + /// + public static string Label_CLIPSkip { + get { + return ResourceManager.GetString("Label_CLIPSkip", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close dialog when finished. /// @@ -1184,6 +1202,24 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Frames Per Second. + /// + public static string Label_Fps { + get { + return ResourceManager.GetString("Label_Fps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Frames. + /// + public static string Label_Frames { + get { + return ResourceManager.GetString("Label_Frames", resourceCulture); + } + } + /// /// Looks up a localized string similar to General. /// @@ -1229,6 +1265,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Image to Video. + /// + public static string Label_ImageToVideo { + get { + return ResourceManager.GetString("Label_ImageToVideo", resourceCulture); + } + } + /// /// Looks up a localized string similar to Image Viewer. /// @@ -1427,6 +1472,24 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Lossless. + /// + public static string Label_Lossless { + get { + return ResourceManager.GetString("Label_Lossless", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Min CFG. + /// + public static string Label_MinCfg { + get { + return ResourceManager.GetString("Label_MinCfg", resourceCulture); + } + } + /// /// Looks up a localized string similar to Missing Image File. /// @@ -1481,6 +1544,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Motion Bucket ID. + /// + public static string Label_MotionBucketId { + get { + return ResourceManager.GetString("Label_MotionBucketId", resourceCulture); + } + } + /// /// Looks up a localized string similar to Networks (Lora / LyCORIS). /// @@ -2156,6 +2228,24 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Method. + /// + public static string Label_VideoOutputMethod { + get { + return ResourceManager.GetString("Label_VideoOutputMethod", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Quality. + /// + public static string Label_VideoQuality { + get { + return ResourceManager.GetString("Label_VideoQuality", resourceCulture); + } + } + /// /// Looks up a localized string similar to Waiting to connect.... /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 93cdca80..d9641154 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -897,4 +897,34 @@ Holiday Mode + + CLIP Skip + + + Image to Video + + + Frames Per Second + + + Min CFG + + + Lossless + + + Frames + + + Motion Bucket ID + + + Augmentation Level + + + Method + + + Quality + diff --git a/StabilityMatrix.Avalonia/Models/CommandItem.cs b/StabilityMatrix.Avalonia/Models/CommandItem.cs new file mode 100644 index 00000000..5c2089d9 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/CommandItem.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.Contracts; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Windows.Input; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Avalonia.Models; + +public partial record CommandItem +{ + public ICommand Command { get; init; } + + public string DisplayName { get; init; } + + public CommandItem(ICommand command, [CallerArgumentExpression("command")] string? commandName = null) + { + Command = command; + DisplayName = commandName == null ? "" : ProcessName(commandName); + } + + [Pure] + private static string ProcessName(string name) + { + name = name.StripEnd("Command"); + + name = SpaceTitleCaseRegex().Replace(name, "$1 $2"); + + return name; + } + + [GeneratedRegex("([a-z])_?([A-Z])")] + private static partial Regex SpaceTitleCaseRegex(); +} diff --git a/StabilityMatrix.Avalonia/Models/ITemplateKey.cs b/StabilityMatrix.Avalonia/Models/ITemplateKey.cs new file mode 100644 index 00000000..34c998d7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/ITemplateKey.cs @@ -0,0 +1,11 @@ +using StabilityMatrix.Avalonia.Controls; + +namespace StabilityMatrix.Avalonia.Models; + +/// +/// Implements a template key for +/// +public interface ITemplateKey +{ + T TemplateKey { get; } +} diff --git a/StabilityMatrix.Avalonia/Models/ImageSource.cs b/StabilityMatrix.Avalonia/Models/ImageSource.cs index 4a8fffb7..f20d316a 100644 --- a/StabilityMatrix.Avalonia/Models/ImageSource.cs +++ b/StabilityMatrix.Avalonia/Models/ImageSource.cs @@ -12,7 +12,7 @@ using StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Avalonia.Models; -public record ImageSource : IDisposable +public record ImageSource : IDisposable, ITemplateKey { private Hash? contentHashBlake3; @@ -55,6 +55,23 @@ public record ImageSource : IDisposable Bitmap = bitmap; } + /// + public ImageSourceTemplateType TemplateKey + { + get + { + var ext = LocalFile?.Extension ?? Path.GetExtension(RemoteUrl?.ToString()); + + if (ext is not null && ext.Equals(".webp", StringComparison.OrdinalIgnoreCase)) + { + // TODO: Check if webp is animated + return ImageSourceTemplateType.WebpAnimation; + } + + return ImageSourceTemplateType.Image; + } + } + [JsonIgnore] public Task BitmapAsync => GetBitmapAsync(); diff --git a/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs b/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs new file mode 100644 index 00000000..14ab15d4 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/ImageSourceTemplateType.cs @@ -0,0 +1,8 @@ +namespace StabilityMatrix.Avalonia.Models; + +public enum ImageSourceTemplateType +{ + Default, + Image, + WebpAnimation +} diff --git a/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs b/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs index f90baa4b..8119abc1 100644 --- a/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs +++ b/StabilityMatrix.Avalonia/Models/Inference/ModuleApplyStepEventArgs.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -using StabilityMatrix.Core.Models.Api.Comfy.Nodes; using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes; +using StabilityMatrix.Core.Models.Api.Comfy.Nodes; namespace StabilityMatrix.Avalonia.Models.Inference; @@ -29,26 +29,19 @@ public class ModuleApplyStepEventArgs : EventArgs /// /// Generation overrides (like hires fix generate, current seed generate, etc.) /// - public IReadOnlyDictionary IsEnabledOverrides { get; init; } = - new Dictionary(); + public IReadOnlyDictionary IsEnabledOverrides { get; init; } = new Dictionary(); public class ModuleApplyStepTemporaryArgs { /// /// Temporary conditioning apply step, used by samplers to apply control net. /// - public ( - ConditioningNodeConnection Positive, - ConditioningNodeConnection Negative - )? Conditioning { get; set; } + public ConditioningConnections? Conditioning { get; set; } /// /// Temporary refiner conditioning apply step, used by samplers to apply control net. /// - public ( - ConditioningNodeConnection Positive, - ConditioningNodeConnection Negative - )? RefinerConditioning { get; set; } + public ConditioningConnections? RefinerConditioning { get; set; } /// /// Temporary model apply step, used by samplers to apply control net. diff --git a/StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs b/StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs new file mode 100644 index 00000000..134a3c0b --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/Inference/VideoOutputMethod.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Avalonia.Models.Inference; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum VideoOutputMethod +{ + Fastest, + Default, + Slowest, +} diff --git a/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs b/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs index 3b8c1cad..3cc0355e 100644 --- a/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs +++ b/StabilityMatrix.Avalonia/Models/InferenceProjectDocument.cs @@ -33,7 +33,7 @@ public class InferenceProjectDocument : ICloneable InferenceImageToImageViewModel => InferenceProjectType.ImageToImage, InferenceTextToImageViewModel => InferenceProjectType.TextToImage, InferenceImageUpscaleViewModel => InferenceProjectType.Upscale, - _ => throw new InvalidOperationException($"Unknown loadable model type: {loadableModel.GetType()}") + InferenceImageToVideoViewModel => InferenceProjectType.ImageToVideo, }, State = loadableModel.SaveStateToJsonObject() }; diff --git a/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs b/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs index eaae74eb..1b4bb017 100644 --- a/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs +++ b/StabilityMatrix.Avalonia/Models/InferenceProjectType.cs @@ -9,7 +9,8 @@ public enum InferenceProjectType TextToImage, ImageToImage, Inpainting, - Upscale + Upscale, + ImageToVideo } public static class InferenceProjectTypeExtensions @@ -22,6 +23,7 @@ public static class InferenceProjectTypeExtensions InferenceProjectType.ImageToImage => typeof(InferenceImageToImageViewModel), InferenceProjectType.Inpainting => null, InferenceProjectType.Upscale => typeof(InferenceImageUpscaleViewModel), + InferenceProjectType.ImageToVideo => typeof(InferenceImageToVideoViewModel), InferenceProjectType.Unknown => null, _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) }; diff --git a/StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs b/StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs new file mode 100644 index 00000000..18982f9a --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/PackageManagerNavigationOptions.cs @@ -0,0 +1,10 @@ +using StabilityMatrix.Core.Models.Packages; + +namespace StabilityMatrix.Avalonia.Models; + +public record PackageManagerNavigationOptions +{ + public bool OpenInstallerDialog { get; init; } + + public BasePackage? InstallerSelectedPackage { get; init; } +} diff --git a/StabilityMatrix.Avalonia/Services/INavigationService.cs b/StabilityMatrix.Avalonia/Services/INavigationService.cs index 3f7d0885..f1aa8ba4 100644 --- a/StabilityMatrix.Avalonia/Services/INavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/INavigationService.cs @@ -19,10 +19,7 @@ public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypePar /// /// Navigate to the view of the given view model type. /// - void NavigateTo( - NavigationTransitionInfo? transitionInfo = null, - object? param = null - ) + void NavigateTo(NavigationTransitionInfo? transitionInfo = null, object? param = null) where TViewModel : ViewModelBase; /// @@ -38,4 +35,6 @@ public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypePar /// Navigate to the view of the given view model. /// void NavigateTo(ViewModelBase viewModel, NavigationTransitionInfo? transitionInfo = null); + + bool GoBack(); } diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs index 5b4bd630..c95d3068 100644 --- a/StabilityMatrix.Avalonia/Services/NavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/NavigationService.cs @@ -7,7 +7,9 @@ using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.Services; @@ -20,6 +22,10 @@ namespace StabilityMatrix.Avalonia.Services; ImplType = typeof(NavigationService), InterfaceType = typeof(INavigationService) )] +[Singleton( + ImplType = typeof(NavigationService), + InterfaceType = typeof(INavigationService) +)] public class NavigationService : INavigationService { private Frame? _frame; @@ -33,10 +39,7 @@ public class NavigationService : INavigationService } /// - public void NavigateTo( - NavigationTransitionInfo? transitionInfo = null, - object? param = null - ) + public void NavigateTo(NavigationTransitionInfo? transitionInfo = null, object? param = null) where TViewModel : ViewModelBase { if (_frame is null) @@ -70,10 +73,7 @@ public class NavigationService : INavigationService } ); - TypedNavigation?.Invoke( - this, - new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) } - ); + TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) }); } /// @@ -120,10 +120,7 @@ public class NavigationService : INavigationService } ); - TypedNavigation?.Invoke( - this, - new TypedNavigationEventArgs { ViewModelType = viewModelType } - ); + TypedNavigation?.Invoke(this, new TypedNavigationEventArgs { ViewModelType = viewModelType }); } /// @@ -159,13 +156,36 @@ public class NavigationService : INavigationService } ); + TypedNavigation?.Invoke( + this, + new TypedNavigationEventArgs { ViewModelType = viewModel.GetType(), ViewModel = viewModel } + ); + } + + public bool GoBack() + { + if (_frame?.Content is IHandleNavigation navigationHandler) + { + var wentBack = navigationHandler.GoBack(); + if (wentBack) + { + return true; + } + } + + if (_frame is not { CanGoBack: true }) + return false; + TypedNavigation?.Invoke( this, new TypedNavigationEventArgs { - ViewModelType = viewModel.GetType(), - ViewModel = viewModel + ViewModelType = _frame.BackStack.Last().SourcePageType, + ViewModel = _frame.BackStack.Last().Context } ); + + _frame.GoBack(); + return true; } } diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 54e5d85b..51fd37cc 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -1,5 +1,8 @@  + Stability Matrix + Assets/AppIcon.icns + APPL WinExe net8.0 win-x64;linux-x64;osx-x64;osx-arm64 @@ -8,12 +11,19 @@ app.manifest true ./Assets/Icon.ico - 2.7.0-pre.999 + 2.8.0-dev.999 $(Version) true true + + + StabilityMatrix.URL + stabilitymatrix;stabilitymatrix:// + + + @@ -30,6 +40,7 @@ + @@ -41,6 +52,7 @@ + @@ -91,6 +103,7 @@ + @@ -128,6 +141,12 @@ + + + + Always + + @@ -142,6 +161,30 @@ True Resources.resx + + InferenceImageToVideoView.axaml + Code + + + VideoGenerationSettingsCard.axaml + Code + + + VideoOutputSettingsCard.axaml + Code + + + NewPackageManagerPage.axaml + Code + + + PackageInstallDetailView.axaml + Code + + + NewInstallerDialog.axaml + Code + diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml new file mode 100644 index 00000000..e2e71476 --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/BetterComboBoxStyles.axaml @@ -0,0 +1,254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml index d0c2007e..d0c54b78 100644 --- a/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml +++ b/StabilityMatrix.Avalonia/Styles/FAComboBoxStyles.axaml @@ -2,33 +2,36 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" + xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" + xmlns:mocks="using:StabilityMatrix.Avalonia.DesignData" xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core" + xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"> - - + + + + - + - - + - + - + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs index 08a41a64..7a50ee01 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Management; +using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -13,6 +14,8 @@ using AsyncAwaitBestPractices; using Avalonia.Controls.Notifications; using Avalonia.Threading; using CommunityToolkit.Mvvm.Input; +using ExifLibrary; +using MetadataExtractor.Formats.Exif; using NLog; using Refit; using SkiaSharp; @@ -24,6 +27,7 @@ using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Inference; using StabilityMatrix.Avalonia.ViewModels.Inference.Modules; +using StabilityMatrix.Core.Animation; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; @@ -42,7 +46,9 @@ namespace StabilityMatrix.Avalonia.ViewModels.Base; /// This includes a progress reporter, image output view model, and generation virtual methods. /// [SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")] -public abstract partial class InferenceGenerationViewModelBase : InferenceTabViewModelBase, IImageGalleryComponent +public abstract partial class InferenceGenerationViewModelBase + : InferenceTabViewModelBase, + IImageGalleryComponent { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -91,13 +97,22 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie ImageGenerationEventArgs args, int batchNum = 0, int batchTotal = 0, - bool isGrid = false + bool isGrid = false, + string fileExtension = "png" ) { var defaultOutputDir = settingsManager.ImagesInferenceDirectory; defaultOutputDir.Create(); - return WriteOutputImageAsync(imageStream, defaultOutputDir, args, batchNum, batchTotal, isGrid); + return WriteOutputImageAsync( + imageStream, + defaultOutputDir, + args, + batchNum, + batchTotal, + isGrid, + fileExtension + ); } /// @@ -109,7 +124,8 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie ImageGenerationEventArgs args, int batchNum = 0, int batchTotal = 0, - bool isGrid = false + bool isGrid = false, + string fileExtension = "png" ) { var formatTemplateStr = settingsManager.Settings.InferenceOutputImageFileNameFormat; @@ -128,7 +144,10 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie ) { // Fallback to default - Logger.Warn("Failed to parse format template: {FormatTemplate}, using default", formatTemplateStr); + Logger.Warn( + "Failed to parse format template: {FormatTemplate}, using default", + formatTemplateStr + ); format = FileNameFormat.Parse(FileNameFormat.DefaultTemplate, formatProvider); } @@ -144,7 +163,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie } var fileName = format.GetFileName(); - var file = outputDir.JoinFile($"{fileName}.png"); + var file = outputDir.JoinFile($"{fileName}.{fileExtension}"); // Until the file is free, keep adding _{i} to the end for (var i = 0; i < 100; i++) @@ -152,14 +171,14 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie if (!file.Exists) break; - file = outputDir.JoinFile($"{fileName}_{i + 1}.png"); + file = outputDir.JoinFile($"{fileName}_{i + 1}.{fileExtension}"); } // If that fails, append an 7-char uuid if (file.Exists) { var uuid = Guid.NewGuid().ToString("N")[..7]; - file = outputDir.JoinFile($"{fileName}_{uuid}.png"); + file = outputDir.JoinFile($"{fileName}_{uuid}.{fileExtension}"); } await using var fileStream = file.Info.OpenWrite(); @@ -271,17 +290,13 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie Task.Run( async () => { - try + var delayTime = 250 - (int)timer.ElapsedMilliseconds; + if (delayTime > 0) { - var delayTime = 250 - (int)timer.ElapsedMilliseconds; - if (delayTime > 0) - { - await Task.Delay(delayTime, cancellationToken); - } - // ReSharper disable once AccessToDisposedClosure - AttachRunningNodeChangedHandler(promptTask); + await Task.Delay(delayTime, cancellationToken); } - catch (TaskCanceledException) { } + // ReSharper disable once AccessToDisposedClosure + AttachRunningNodeChangedHandler(promptTask); }, cancellationToken ) @@ -305,10 +320,17 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie // Get output images var imageOutputs = await client.GetImagesForExecutedPromptAsync(promptTask.Id, cancellationToken); - if (imageOutputs.Values.All(images => images is null or { Count: 0 })) + if ( + !imageOutputs.TryGetValue(args.OutputNodeNames[0], out var images) + || images is not { Count: > 0 } + ) { // No images match - notificationService.Show("No output", "Did not receive any output images", NotificationType.Warning); + notificationService.Show( + "No output", + "Did not receive any output images", + NotificationType.Warning + ); return; } @@ -320,7 +342,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie ImageGalleryCardViewModel.ImageSources.Clear(); } - await ProcessAllOutputImages(imageOutputs, args); + await ProcessOutputImages(images, args); } finally { @@ -338,30 +360,12 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie } } - private async Task ProcessAllOutputImages( - IReadOnlyDictionary?> images, - ImageGenerationEventArgs args - ) - { - foreach (var (nodeName, imageList) in images) - { - if (imageList is null) - { - Logger.Warn("No images for node {NodeName}", nodeName); - continue; - } - - await ProcessOutputImages(imageList, args, nodeName.Replace('_', ' ')); - } - } - /// /// Handles image output metadata for generation runs /// private async Task ProcessOutputImages( IReadOnlyCollection images, - ImageGenerationEventArgs args, - string? imageLabel = null + ImageGenerationEventArgs args ) { var client = args.Client; @@ -405,14 +409,64 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie ); } - var bytesWithMetadata = PngDataHelper.AddMetadata(imageArray, parameters, project); + if (comfyImage.FileName.EndsWith(".png")) + { + var bytesWithMetadata = PngDataHelper.AddMetadata(imageArray, parameters, project); + + // Write using generated name + var filePath = await WriteOutputImageAsync( + new MemoryStream(bytesWithMetadata), + args, + i + 1, + images.Count + ); - // Write using generated name - var filePath = await WriteOutputImageAsync(new MemoryStream(bytesWithMetadata), args, i + 1, images.Count); + outputImages.Add(new ImageSource(filePath)); + EventManager.Instance.OnImageFileAdded(filePath); + } + else if (comfyImage.FileName.EndsWith(".webp")) + { + var opts = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + var paramsJson = JsonSerializer.Serialize(parameters, opts); + var smProject = JsonSerializer.Serialize(project, opts); + var metadata = new Dictionary + { + { ExifTag.ImageDescription, paramsJson }, + { ExifTag.Software, smProject } + }; + + var bytesWithMetadata = ImageMetadata.AddMetadataToWebp(imageArray, metadata); + + // Write using generated name + var filePath = await WriteOutputImageAsync( + new MemoryStream(bytesWithMetadata.ToArray()), + args, + i + 1, + images.Count, + fileExtension: Path.GetExtension(comfyImage.FileName).Replace(".", "") + ); - outputImages.Add(new ImageSource(filePath) { Label = imageLabel }); + outputImages.Add(new ImageSource(filePath)); + EventManager.Instance.OnImageFileAdded(filePath); + } + else + { + // Write using generated name + var filePath = await WriteOutputImageAsync( + new MemoryStream(imageArray), + args, + i + 1, + images.Count, + fileExtension: Path.GetExtension(comfyImage.FileName).Replace(".", "") + ); - EventManager.Instance.OnImageFileAdded(filePath); + outputImages.Add(new ImageSource(filePath)); + EventManager.Instance.OnImageFileAdded(filePath); + } } // Download all images to make grid, if multiple @@ -430,11 +484,14 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie var gridBytesWithMetadata = PngDataHelper.AddMetadata(gridBytes, args.Parameters!, args.Project!); // Save to disk - var gridPath = await WriteOutputImageAsync(new MemoryStream(gridBytesWithMetadata), args, isGrid: true); + var gridPath = await WriteOutputImageAsync( + new MemoryStream(gridBytesWithMetadata), + args, + isGrid: true + ); // Insert to start of images - var gridImage = new ImageSource(gridPath) { Label = imageLabel }; - + var gridImage = new ImageSource(gridPath); // Preload await gridImage.GetBitmapAsync(); ImageGalleryCardViewModel.ImageSources.Add(gridImage); @@ -465,7 +522,10 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie /// Optional overrides (side buttons) /// Cancellation token [RelayCommand(IncludeCancelCommand = true, FlowExceptionsToTaskScheduler = true)] - private async Task GenerateImage(GenerateFlags options = default, CancellationToken cancellationToken = default) + private async Task GenerateImage( + GenerateFlags options = default, + CancellationToken cancellationToken = default + ) { var overrides = GenerateOverrides.FromFlags(options); @@ -475,12 +535,7 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie } catch (OperationCanceledException) { - Logger.Debug("Image Generation Canceled"); - } - catch (ValidationException e) - { - Logger.Debug("Image Generation Validation Error: {Message}", e.Message); - notificationService.Show("Validation Error", e.Message, NotificationType.Error); + Logger.Debug($"Image Generation Canceled"); } } @@ -513,17 +568,15 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie /// protected virtual void OnProgressUpdateReceived(object? sender, ComfyProgressUpdateEventArgs args) { - Dispatcher - .UIThread - .Post(() => - { - OutputProgress.Value = args.Value; - OutputProgress.Maximum = args.Maximum; - OutputProgress.IsIndeterminate = false; + Dispatcher.UIThread.Post(() => + { + OutputProgress.Value = args.Value; + OutputProgress.Maximum = args.Maximum; + OutputProgress.IsIndeterminate = false; - OutputProgress.Text = - $"({args.Value} / {args.Maximum})" + (args.RunningNode != null ? $" {args.RunningNode}" : ""); - }); + OutputProgress.Text = + $"({args.Value} / {args.Maximum})" + (args.RunningNode != null ? $" {args.RunningNode}" : ""); + }); } private void AttachRunningNodeChangedHandler(ComfyTask comfyTask) @@ -548,15 +601,13 @@ public abstract partial class InferenceGenerationViewModelBase : InferenceTabVie return; } - Dispatcher - .UIThread - .Post(() => - { - OutputProgress.IsIndeterminate = true; - OutputProgress.Value = 100; - OutputProgress.Maximum = 100; - OutputProgress.Text = nodeName; - }); + Dispatcher.UIThread.Post(() => + { + OutputProgress.IsIndeterminate = true; + OutputProgress.Value = 100; + OutputProgress.Maximum = 100; + OutputProgress.Text = nodeName; + }); } public class ImageGenerationEventArgs : EventArgs diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs index 965b787f..fdd9a520 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs @@ -28,24 +28,16 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private static readonly Type[] SerializerIgnoredTypes = - { - typeof(ICommand), - typeof(IRelayCommand) - }; + private static readonly Type[] SerializerIgnoredTypes = { typeof(ICommand), typeof(IRelayCommand) }; private static readonly string[] SerializerIgnoredNames = { nameof(HasErrors) }; - private static readonly JsonSerializerOptions SerializerOptions = - new() { IgnoreReadOnlyProperties = true }; + private static readonly JsonSerializerOptions SerializerOptions = new() { IgnoreReadOnlyProperties = true }; private static bool ShouldIgnoreProperty(PropertyInfo property) { // Skip if read-only and not IJsonLoadableState - if ( - property.SetMethod is null - && !typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType) - ) + if (property.SetMethod is null && !typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType)) { Logger.ConditionalTrace("Skipping {Property} - read-only", property.Name); return true; @@ -107,11 +99,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState { // Get all of our properties using reflection var properties = GetType().GetProperties(); - Logger.ConditionalTrace( - "Serializing {Type} with {Count} properties", - GetType(), - properties.Length - ); + Logger.ConditionalTrace("Serializing {Type} with {Count} properties", GetType(), properties.Length); foreach (var property in properties) { @@ -119,9 +107,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState // If JsonPropertyName provided, use that as the key if ( - property - .GetCustomAttributes(typeof(JsonPropertyNameAttribute), true) - .FirstOrDefault() + property.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).FirstOrDefault() is JsonPropertyNameAttribute jsonPropertyName ) { @@ -168,10 +154,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState if (property.GetValue(this) is not IJsonLoadableState propertyValue) { // If null, it must have a default constructor - if ( - property.PropertyType.GetConstructor(Type.EmptyTypes) - is not { } constructorInfo - ) + if (property.PropertyType.GetConstructor(Type.EmptyTypes) is not { } constructorInfo) { throw new InvalidOperationException( $"Property {property.Name} is IJsonLoadableState but current object is null and has no default constructor" @@ -188,11 +171,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState } else { - Logger.ConditionalTrace( - "Loading {Property} ({Type})", - property.Name, - property.PropertyType - ); + Logger.ConditionalTrace("Loading {Property} ({Type})", property.Name, property.PropertyType); var propertyValue = value.Deserialize(property.PropertyType, SerializerOptions); property.SetValue(this, propertyValue); @@ -216,11 +195,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState { // Get all of our properties using reflection. var properties = GetType().GetProperties(); - Logger.ConditionalTrace( - "Serializing {Type} with {Count} properties", - GetType(), - properties.Length - ); + Logger.ConditionalTrace("Serializing {Type} with {Count} properties", GetType(), properties.Length); // Create a JSON object to store the state. var state = new JsonObject(); @@ -237,9 +212,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState // If JsonPropertyName provided, use that as the key. if ( - property - .GetCustomAttributes(typeof(JsonPropertyNameAttribute), true) - .FirstOrDefault() + property.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).FirstOrDefault() is JsonPropertyNameAttribute jsonPropertyName ) { @@ -270,11 +243,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState } else { - Logger.ConditionalTrace( - "Serializing {Property} ({Type})", - property.Name, - property.PropertyType - ); + Logger.ConditionalTrace("Serializing {Property} ({Type})", property.Name, property.PropertyType); var value = property.GetValue(this); if (value is not null) { @@ -297,8 +266,7 @@ public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState protected static JsonObject SerializeModel(T model) { var node = JsonSerializer.SerializeToNode(model); - return node?.AsObject() - ?? throw new NullReferenceException("Failed to serialize state to JSON object."); + return node?.AsObject() ?? throw new NullReferenceException("Failed to serialize state to JSON object."); } /// diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs index 4dc4acbc..afdeb085 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs @@ -21,6 +21,7 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -120,30 +121,24 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel var latestVersionInstalled = latestVersion.Files != null - && latestVersion - .Files - .Any( - file => - file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null } - && installedModels.Contains(file.Hashes.BLAKE3) - ); + && latestVersion.Files.Any( + file => + file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null } + && installedModels.Contains(file.Hashes.BLAKE3) + ); // check if any of the ModelVersion.Files.Hashes.BLAKE3 hashes are in the installedModels list var anyVersionInstalled = latestVersionInstalled - || CivitModel - .ModelVersions - .Any( - version => - version.Files != null - && version - .Files - .Any( - file => - file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null } - && installedModels.Contains(file.Hashes.BLAKE3) - ) - ); + || CivitModel.ModelVersions.Any( + version => + version.Files != null + && version.Files.Any( + file => + file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null } + && installedModels.Contains(file.Hashes.BLAKE3) + ) + ); UpdateCardText = latestVersionInstalled ? "Installed" @@ -154,7 +149,6 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel ShowUpdateCard = anyVersionInstalled; } - // Choose and load image based on nsfw setting private void UpdateImage() { var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled; @@ -162,21 +156,17 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel var images = version?.Images; // Try to find a valid image - var image = images?.FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None"); + var image = images + ?.Where(img => LocalModelFile.SupportedImageExtensions.Any(img.Url.Contains)) + .FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None"); if (image != null) { - // var imageStream = await downloadService.GetImageStreamFromUrl(image.Url); - // Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(imageStream); }); CardImage = new Uri(image.Url); return; } // If no valid image found, use no image CardImage = Assets.NoImage; - - // var assetStream = AssetLoader.Open(new Uri("avares://StabilityMatrix.Avalonia/Assets/noimage.png")); - // Otherwise Default image - // Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(assetStream); }); } [RelayCommand] @@ -270,8 +260,7 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel { var prunedDescription = model - .Description - ?.Replace("
", $"{Environment.NewLine}{Environment.NewLine}") + .Description?.Replace("
", $"{Environment.NewLine}{Environment.NewLine}") .Replace("
", $"{Environment.NewLine}{Environment.NewLine}") .Replace("

", $"{Environment.NewLine}{Environment.NewLine}") .Replace("", $"{Environment.NewLine}{Environment.NewLine}") @@ -318,9 +307,9 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel var imageExtension = Path.GetExtension(image.Url).TrimStart('.'); if (imageExtension is "jpg" or "jpeg" or "png") { - var imageDownloadPath = modelFilePath - .Directory! - .JoinFile($"{modelFilePath.NameWithoutExtension}.preview.{imageExtension}"); + var imageDownloadPath = modelFilePath.Directory!.JoinFile( + $"{modelFilePath.NameWithoutExtension}.preview.{imageExtension}" + ); var imageTask = downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); await notificationService.TryAsync(imageTask, "Could not download preview image"); @@ -358,7 +347,8 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel } // Get latest version file - var modelFile = selectedFile ?? modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model); + var modelFile = + selectedFile ?? modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model); if (modelFile is null) { notificationService.Show( @@ -374,7 +364,9 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory); - var downloadDirectory = rootModelsDirectory.JoinDir(model.Type.ConvertTo().GetStringValue()); + var downloadDirectory = rootModelsDirectory.JoinDir( + model.Type.ConvertTo().GetStringValue() + ); // Folders might be missing if user didn't install any packages yet downloadDirectory.Create(); diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs index 333a3a02..33ed693d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs @@ -70,7 +70,14 @@ public partial class CheckpointFile : ViewModelBase public ObservableCollection Badges { get; set; } = new(); - public static readonly string[] SupportedCheckpointExtensions = { ".safetensors", ".pt", ".ckpt", ".pth", ".bin" }; + public static readonly string[] SupportedCheckpointExtensions = + { + ".safetensors", + ".pt", + ".ckpt", + ".pth", + ".bin" + }; private static readonly string[] SupportedImageExtensions = { ".png", ".jpg", ".jpeg", ".gif" }; private static readonly string[] SupportedMetadataExtensions = { ".json" }; @@ -95,7 +102,9 @@ public partial class CheckpointFile : ViewModelBase { if (string.IsNullOrEmpty(FilePath)) { - throw new InvalidOperationException("Cannot get connected model info file path when FilePath is empty"); + throw new InvalidOperationException( + "Cannot get connected model info file path when FilePath is empty" + ); } var modelNameNoExt = Path.GetFileNameWithoutExtension((string?)FilePath); var modelDir = Path.GetDirectoryName((string?)FilePath) ?? ""; @@ -224,10 +233,21 @@ public partial class CheckpointFile : ViewModelBase return App.Clipboard.SetTextAsync(words); } + [RelayCommand] + private Task CopyModelUrl() + { + return ConnectedModel == null + ? Task.CompletedTask + : App.Clipboard.SetTextAsync($"https://civitai.com/models/{ConnectedModel.ModelId}"); + } + [RelayCommand] private async Task FindConnectedMetadata(bool forceReimport = false) { - if (App.Services.GetService(typeof(IMetadataImportService)) is not IMetadataImportService importService) + if ( + App.Services.GetService(typeof(IMetadataImportService)) + is not IMetadataImportService importService + ) return; IsLoading = true; @@ -298,7 +318,9 @@ public partial class CheckpointFile : ViewModelBase } checkpointFile.PreviewImagePath = SupportedImageExtensions - .Select(ext => Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(file)}.preview{ext}")) + .Select( + ext => Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(file)}.preview{ext}") + ) .Where(File.Exists) .FirstOrDefault(); @@ -324,7 +346,11 @@ public partial class CheckpointFile : ViewModelBase ) continue; - var checkpointFile = new CheckpointFile { Title = Path.GetFileNameWithoutExtension(file), FilePath = file }; + var checkpointFile = new CheckpointFile + { + Title = Path.GetFileNameWithoutExtension(file), + FilePath = file + }; var jsonPath = Path.Combine( Path.GetDirectoryName(file) ?? "", @@ -432,5 +458,6 @@ public partial class CheckpointFile : ViewModelBase } } - public static IEqualityComparer FilePathComparer { get; } = new FilePathEqualityComparer(); + public static IEqualityComparer FilePathComparer { get; } = + new FilePathEqualityComparer(); } diff --git a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs index 34253d7b..f6893400 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs @@ -7,9 +7,9 @@ using System.Threading.Tasks.Dataflow; using Avalonia.Threading; using AvaloniaEdit.Document; using CommunityToolkit.Mvvm.ComponentModel; +using NLog; using Nito.AsyncEx; using Nito.AsyncEx.Synchronous; -using NLog; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Processes; diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs index aad4cc87..fafd1206 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using FluentAvalonia.UI.Controls; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views; @@ -65,8 +66,9 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa this.packageFactory = packageFactory; // Get comfy type installed packages - var comfyPackages = this.settingsManager.Settings.InstalledPackages - .Where(p => p.PackageName == "ComfyUI") + var comfyPackages = this.settingsManager.Settings.InstalledPackages.Where( + p => p.PackageName == "ComfyUI" + ) .ToImmutableArray(); InstalledPackages = comfyPackages; @@ -103,13 +105,14 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa { Dispatcher.UIThread.Post(() => { - navigationService.NavigateTo( - param: new PackageManagerPage.PackageManagerNavigationOptions + navigationService.NavigateTo( + param: new PackageManagerNavigationOptions { OpenInstallerDialog = true, InstallerSelectedPackage = packageFactory .GetAllAvailablePackages() - .First(p => p is ComfyUI) + .OfType() + .First() } ); }); @@ -138,9 +141,7 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa var dialog = new BetterContentDialog { Content = new InferenceConnectionHelpDialog { DataContext = this }, - PrimaryButtonCommand = IsInstallMode - ? NavigateToInstallCommand - : LaunchSelectedPackageCommand, + PrimaryButtonCommand = IsInstallMode ? NavigateToInstallCommand : LaunchSelectedPackageCommand, PrimaryButtonText = IsInstallMode ? Resources.Action_Install : Resources.Action_Launch, CloseButtonText = Resources.Action_Close, DefaultButton = ContentDialogButton.Primary diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs index c98d41bf..46fd634b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs @@ -20,6 +20,7 @@ using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Factory; @@ -34,9 +35,7 @@ using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; -[ManagedService] -[Transient] -public partial class InstallerViewModel : ContentDialogViewModelBase +public partial class InstallerViewModel : PageViewModelBase { private readonly ISettingsManager settingsManager; private readonly IPackageFactory packageFactory; @@ -46,6 +45,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase private readonly IPrerequisiteHelper prerequisiteHelper; private readonly ILogger logger; + public override string Title => "Add Package"; + [ObservableProperty] private BasePackage selectedPackage; @@ -90,11 +91,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase // Version types (release or commit) [ObservableProperty] - [NotifyPropertyChangedFor( - nameof(ReleaseLabelText), - nameof(IsReleaseMode), - nameof(SelectedVersion) - )] + [NotifyPropertyChangedFor(nameof(ReleaseLabelText), nameof(IsReleaseMode), nameof(SelectedVersion))] private PackageVersionType selectedVersionType = PackageVersionType.Commit; [ObservableProperty] @@ -106,22 +103,16 @@ public partial class InstallerViewModel : ContentDialogViewModelBase [NotifyPropertyChangedFor(nameof(CanInstall))] private bool isLoading; - public string ReleaseLabelText => - IsReleaseMode ? Resources.Label_Version : Resources.Label_Branch; + public string ReleaseLabelText => IsReleaseMode ? Resources.Label_Version : Resources.Label_Branch; public bool IsReleaseMode { get => SelectedVersionType == PackageVersionType.GithubRelease; - set => - SelectedVersionType = value - ? PackageVersionType.GithubRelease - : PackageVersionType.Commit; + set => SelectedVersionType = value ? PackageVersionType.GithubRelease : PackageVersionType.Commit; } - public bool IsReleaseModeAvailable => - AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease); + public bool IsReleaseModeAvailable => AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease); public bool ShowTorchVersionOptions => SelectedTorchVersion != TorchVersion.None; - public bool CanInstall => - !string.IsNullOrWhiteSpace(InstallName) && !ShowDuplicateWarning && !IsLoading; + public bool CanInstall => !string.IsNullOrWhiteSpace(InstallName) && !ShowDuplicateWarning && !IsLoading; public IEnumerable Steps { get; set; } @@ -148,6 +139,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase AvailablePackages = new ObservableCollection( filtered.Any() ? filtered : packageFactory.GetAllAvailablePackages() ); + SelectedPackage = AvailablePackages.FirstOrDefault(); ShowIncompatiblePackages = !filtered.Any(); } @@ -207,7 +199,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase ); if (result.IsSuccessful) { - OnPrimaryButtonClick(); + // OnPrimaryButtonClick(); } else { @@ -254,10 +246,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase if (IsReleaseMode) { downloadOptions.VersionTag = - SelectedVersion?.TagName - ?? throw new NullReferenceException("Selected version is null"); - downloadOptions.IsLatest = - AvailableVersions?.First().TagName == downloadOptions.VersionTag; + SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null"); + downloadOptions.IsLatest = AvailableVersions?.First().TagName == downloadOptions.VersionTag; downloadOptions.IsPrerelease = SelectedVersion.IsPrerelease; installedVersion.InstalledReleaseVersion = downloadOptions.VersionTag; @@ -268,21 +258,15 @@ public partial class InstallerViewModel : ContentDialogViewModelBase downloadOptions.CommitHash = SelectedCommit?.Sha ?? throw new NullReferenceException("Selected commit is null"); downloadOptions.BranchName = - SelectedVersion?.TagName - ?? throw new NullReferenceException("Selected version is null"); + SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null"); downloadOptions.IsLatest = AvailableCommits?.First().Sha == SelectedCommit.Sha; installedVersion.InstalledBranch = - SelectedVersion?.TagName - ?? throw new NullReferenceException("Selected version is null"); + SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null"); installedVersion.InstalledCommitSha = downloadOptions.CommitHash; } - var downloadStep = new DownloadPackageVersionStep( - SelectedPackage, - installLocation, - downloadOptions - ); + var downloadStep = new DownloadPackageVersionStep(SelectedPackage, installLocation, downloadOptions); var installStep = new InstallPackageStep( SelectedPackage, SelectedTorchVersion, @@ -326,7 +310,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase public void Cancel() { - OnCloseButtonClick(); + // OnCloseButtonClick(); } partial void OnShowIncompatiblePackagesChanged(bool value) @@ -351,9 +335,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase else { // First try to find the package-defined main branch - var version = AvailableVersions.FirstOrDefault( - x => x.TagName == SelectedPackage.MainBranch - ); + var version = AvailableVersions.FirstOrDefault(x => x.TagName == SelectedPackage.MainBranch); // If not found, try main version ??= AvailableVersions.FirstOrDefault(x => x.TagName == "main"); @@ -406,8 +388,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase if (SelectedPackage is null || Design.IsDesignMode) return; - Dispatcher.UIThread - .InvokeAsync(async () => + Dispatcher + .UIThread.InvokeAsync(async () => { logger.LogDebug($"Release mode: {IsReleaseMode}"); var versionOptions = await SelectedPackage.GetAllVersionOptions(); @@ -425,9 +407,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase if (!IsReleaseMode) { - var commits = ( - await SelectedPackage.GetAllCommits(SelectedVersion.TagName) - )?.ToList(); + var commits = (await SelectedPackage.GetAllCommits(SelectedVersion.TagName))?.ToList(); if (commits is null || commits.Count == 0) return; @@ -506,4 +486,6 @@ public partial class InstallerViewModel : ContentDialogViewModelBase .SafeFireAndForget(); } } + + public override IconSource IconSource { get; } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs index 17740c40..73922420 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs @@ -8,20 +8,42 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; public partial class ModelVersionViewModel : ObservableObject { - [ObservableProperty] private CivitModelVersion modelVersion; - [ObservableProperty] private ObservableCollection civitFileViewModels; - [ObservableProperty] private bool isInstalled; + private readonly HashSet installedModelHashes; + + [ObservableProperty] + private CivitModelVersion modelVersion; + + [ObservableProperty] + private ObservableCollection civitFileViewModels; + + [ObservableProperty] + private bool isInstalled; public ModelVersionViewModel(HashSet installedModelHashes, CivitModelVersion modelVersion) { + this.installedModelHashes = installedModelHashes; ModelVersion = modelVersion; - IsInstalled = ModelVersion.Files?.Any(file => - file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} && - installedModelHashes.Contains(file.Hashes.BLAKE3)) ?? false; + IsInstalled = + ModelVersion.Files?.Any( + file => + file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null } + && installedModelHashes.Contains(file.Hashes.BLAKE3) + ) ?? false; CivitFileViewModels = new ObservableCollection( - ModelVersion.Files?.Select(file => new CivitFileViewModel(installedModelHashes, file)) ?? - new List()); + ModelVersion.Files?.Select(file => new CivitFileViewModel(installedModelHashes, file)) + ?? new List() + ); + } + + public void RefreshInstallStatus() + { + IsInstalled = + ModelVersion.Files?.Any( + file => + file is { Type: CivitFileType.Model, Hashes.BLAKE3: not null } + && installedModelHashes.Contains(file.Hashes.BLAKE3) + ) ?? false; } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs index b7d00f8e..6f00313b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PythonPackagesViewModel.cs @@ -140,10 +140,7 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase ? UpgradePackageVersion( item.Package.Name, item.SelectedVersion, - PythonPackagesItemViewModel.GetKnownIndexUrl( - item.Package.Name, - item.SelectedVersion - ), + PythonPackagesItemViewModel.GetKnownIndexUrl(item.Package.Name, item.SelectedVersion), isDowngrade: item.CanDowngrade ) : Task.CompletedTask; @@ -167,9 +164,7 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase Resources.Label_ConfirmQuestion ); - dialog.PrimaryButtonText = isDowngrade - ? Resources.Action_Downgrade - : Resources.Action_Upgrade; + dialog.PrimaryButtonText = isDowngrade ? Resources.Action_Downgrade : Resources.Action_Upgrade; dialog.IsPrimaryButtonEnabled = true; dialog.DefaultButton = ContentDialogButton.Primary; dialog.CloseButtonText = Resources.Action_Cancel; @@ -225,7 +220,7 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase var dialog = DialogHelper.CreateTextEntryDialog("Install Package", "", fields); var result = await dialog.ShowAsync(); - if (result != ContentDialogResult.Primary || fields[0].Text is not { } packageName) + if (result != ContentDialogResult.Primary || fields[0].Text is not { } packageArgs) { return; } @@ -236,14 +231,14 @@ public partial class PythonPackagesViewModel : ContentDialogViewModelBase { VenvDirectory = VenvPath, WorkingDirectory = VenvPath.Parent, - Args = new[] { "install", packageName } + Args = new ProcessArgs(packageArgs).Prepend("install") } }; var runner = new PackageModificationRunner { ShowDialogOnStart = true, - ModificationCompleteMessage = $"Installed Python Package '{packageName}'" + ModificationCompleteMessage = $"Installed Python Package '{packageArgs}'" }; EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps(steps); diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs index b5cb4be9..ef7c2ddf 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs @@ -1,15 +1,22 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls.Notifications; using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; @@ -20,6 +27,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase { private readonly ISettingsManager settingsManager; private readonly IDownloadService downloadService; + private readonly IModelIndexService modelIndexService; + private readonly INotificationService notificationService; public required ContentDialog Dialog { get; set; } public required IReadOnlyList Versions { get; set; } @@ -56,10 +65,17 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase public int DisplayedPageNumber => SelectedImageIndex + 1; - public SelectModelVersionViewModel(ISettingsManager settingsManager, IDownloadService downloadService) + public SelectModelVersionViewModel( + ISettingsManager settingsManager, + IDownloadService downloadService, + IModelIndexService modelIndexService, + INotificationService notificationService + ) { this.settingsManager = settingsManager; this.downloadService = downloadService; + this.modelIndexService = modelIndexService; + this.notificationService = notificationService; } public override void OnLoaded() @@ -99,6 +115,8 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase partial void OnSelectedFileChanged(CivitFileViewModel? value) { + if (value is { IsInstalled: true }) { } + var canImport = true; if (settingsManager.IsLibraryDirSet) { @@ -133,6 +151,90 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase Dialog.Hide(ContentDialogResult.Primary); } + public async Task Delete() + { + if (SelectedFile == null) + return; + + var fileToDelete = SelectedFile; + var originalSelectedVersionVm = SelectedVersionViewModel; + + var hash = fileToDelete.CivitFile.Hashes.BLAKE3; + if (string.IsNullOrWhiteSpace(hash)) + { + notificationService.Show( + "Error deleting file", + "Could not delete model, hash is missing.", + NotificationType.Error + ); + return; + } + + var matchingModels = (await modelIndexService.FindByHashAsync(hash)).ToList(); + + if (matchingModels.Count == 0) + { + await modelIndexService.RefreshIndex(); + matchingModels = (await modelIndexService.FindByHashAsync(hash)).ToList(); + + if (matchingModels.Count == 0) + { + notificationService.Show( + "Error deleting file", + "Could not delete model, model not found in index.", + NotificationType.Error + ); + return; + } + } + + var dialog = new BetterContentDialog + { + Title = Resources.Label_AreYouSure, + MaxDialogWidth = 750, + MaxDialogHeight = 850, + PrimaryButtonText = Resources.Action_Yes, + IsPrimaryButtonEnabled = true, + IsSecondaryButtonEnabled = false, + CloseButtonText = Resources.Action_Cancel, + DefaultButton = ContentDialogButton.Close, + Content = + $"The following files:\n{string.Join('\n', matchingModels.Select(x => $"- {x.FileName}"))}\n" + + "and all associated metadata files will be deleted. Are you sure?", + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + foreach (var localModel in matchingModels) + { + var checkpointPath = new FilePath(localModel.GetFullPath(settingsManager.ModelsDirectory)); + if (File.Exists(checkpointPath)) + { + File.Delete(checkpointPath); + } + + var previewPath = localModel.GetPreviewImageFullPath(settingsManager.ModelsDirectory); + if (File.Exists(previewPath)) + { + File.Delete(previewPath); + } + + var cmInfoPath = checkpointPath.ToString().Replace(checkpointPath.Extension, ".cm-info.json"); + if (File.Exists(cmInfoPath)) + { + File.Delete(cmInfoPath); + } + + await modelIndexService.RemoveModelAsync(localModel); + } + + settingsManager.Transaction(settings => settings.InstalledModelHashes?.Remove(hash)); + fileToDelete.IsInstalled = false; + originalSelectedVersionVm?.RefreshInstallStatus(); + } + } + public void PreviousImage() { if (SelectedImageIndex > 0) diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 5cab55c2..8a5dae66 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -126,10 +126,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase ShowProgressBar = true; IsProgressIndeterminate = true; - UpdateText = string.Format( - Resources.TextTemplate_UpdatingPackage, - Resources.Label_StabilityMatrix - ); + UpdateText = string.Format(Resources.TextTemplate_UpdatingPackage, Resources.Label_StabilityMatrix); try { @@ -173,10 +170,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase UpdateText = "Getting a few things ready..."; await using (new MinimumDelay(500, 1000)) { - Process.Start( - UpdateHelper.ExecutablePath, - $"--wait-for-exit-pid {Environment.ProcessId}" - ); + Process.Start(UpdateHelper.ExecutablePath, $"--wait-for-exit-pid {Environment.ProcessId}"); } UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; @@ -189,7 +183,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase App.Shutdown(); } - + internal async Task GetReleaseNotes(string changelogUrl) { using var client = httpClientFactory.CreateClient(); @@ -262,15 +256,10 @@ public partial class UpdateViewModel : ContentDialogViewModelBase // Join all blocks until and excluding the current version // If we're on a pre-release, include the current release - var currentVersionBlock = results.FindIndex( - x => x.Version == currentVersion.WithoutMetadata() - ); + var currentVersionBlock = results.FindIndex(x => x.Version == currentVersion.WithoutMetadata()); // For mismatching build metadata, add one - if ( - currentVersionBlock != -1 - && results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata - ) + if (currentVersionBlock != -1 && results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata) { currentVersionBlock++; } @@ -278,9 +267,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase // Support for previous pre-release without changelogs if (currentVersionBlock == -1) { - currentVersionBlock = results.FindIndex( - x => x.Version == currentVersion.WithoutPrereleaseOrMetadata() - ); + currentVersionBlock = results.FindIndex(x => x.Version == currentVersion.WithoutPrereleaseOrMetadata()); // Add 1 if found to include the current release if (currentVersionBlock != -1) diff --git a/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs new file mode 100644 index 00000000..7db83434 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/ExtensionViewModel.cs @@ -0,0 +1,13 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Models.Packages.Extensions; + +namespace StabilityMatrix.Avalonia.ViewModels; + +public partial class ExtensionViewModel() : ViewModelBase +{ + [ObservableProperty] + private bool isSelected; + + public ExtensionBase Extension { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs index 72c47db1..46815f62 100644 --- a/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; using AsyncAwaitBestPractices; using CommunityToolkit.Mvvm.ComponentModel; @@ -42,20 +43,32 @@ public partial class FirstLaunchSetupViewModel : ViewModelBase private async Task SetGpuInfo() { GpuInfo[] gpuInfo; + await using (new MinimumDelay(800, 1200)) { // Query GPU info gpuInfo = await Task.Run(() => HardwareHelper.IterGpuInfo().ToArray()); } + // First Nvidia GPU - var activeGpu = gpuInfo.FirstOrDefault(gpu => gpu.Name?.ToLowerInvariant().Contains("nvidia") ?? false); + var activeGpu = gpuInfo.FirstOrDefault( + gpu => gpu.Name?.Contains("nvidia", StringComparison.InvariantCultureIgnoreCase) ?? false + ); var isNvidia = activeGpu is not null; + // Otherwise first GPU activeGpu ??= gpuInfo.FirstOrDefault(); + GpuInfoText = activeGpu is null ? "No GPU detected" : $"{activeGpu.Name} ({Size.FormatBytes(activeGpu.MemoryBytes)})"; + // Always succeed for macos arm + if (Compat.IsMacOS && Compat.IsArm) + { + return true; + } + return isNvidia; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs new file mode 100644 index 00000000..b64a4801 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageToVideoViewModel.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using NLog; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Inference.Video; +using StabilityMatrix.Avalonia.Views.Inference; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api.Comfy; +using StabilityMatrix.Core.Models.Api.Comfy.Nodes; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Inference; + +[View(typeof(InferenceImageToVideoView), persistent: true)] +[ManagedService] +[Transient] +public partial class InferenceImageToVideoViewModel + : InferenceGenerationViewModelBase, + IParametersLoadableState +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private readonly INotificationService notificationService; + private readonly IModelIndexService modelIndexService; + + [JsonIgnore] + public StackCardViewModel StackCardViewModel { get; } + + [JsonPropertyName("Model")] + public ImgToVidModelCardViewModel ModelCardViewModel { get; } + + [JsonPropertyName("Sampler")] + public SamplerCardViewModel SamplerCardViewModel { get; } + + [JsonPropertyName("BatchSize")] + public BatchSizeCardViewModel BatchSizeCardViewModel { get; } + + [JsonPropertyName("Seed")] + public SeedCardViewModel SeedCardViewModel { get; } + + [JsonPropertyName("ImageLoader")] + public SelectImageCardViewModel SelectImageCardViewModel { get; } + + [JsonPropertyName("Conditioning")] + public SvdImgToVidConditioningViewModel SvdImgToVidConditioningViewModel { get; } + + [JsonPropertyName("VideoOutput")] + public VideoOutputSettingsCardViewModel VideoOutputSettingsCardViewModel { get; } + + public InferenceImageToVideoViewModel( + INotificationService notificationService, + IInferenceClientManager inferenceClientManager, + ISettingsManager settingsManager, + ServiceManager vmFactory, + IModelIndexService modelIndexService + ) + : base(vmFactory, inferenceClientManager, notificationService, settingsManager) + { + this.notificationService = notificationService; + this.modelIndexService = modelIndexService; + + // Get sub view models from service manager + + SeedCardViewModel = vmFactory.Get(); + SeedCardViewModel.GenerateNewSeed(); + + ModelCardViewModel = vmFactory.Get(); + + SamplerCardViewModel = vmFactory.Get(samplerCard => + { + samplerCard.IsDimensionsEnabled = true; + samplerCard.IsCfgScaleEnabled = true; + samplerCard.IsSamplerSelectionEnabled = true; + samplerCard.IsSchedulerSelectionEnabled = true; + samplerCard.CfgScale = 2.5d; + samplerCard.SelectedSampler = ComfySampler.Euler; + samplerCard.SelectedScheduler = ComfyScheduler.Karras; + samplerCard.IsDenoiseStrengthEnabled = true; + samplerCard.DenoiseStrength = 1.0f; + }); + + BatchSizeCardViewModel = vmFactory.Get(); + + SelectImageCardViewModel = vmFactory.Get(); + SvdImgToVidConditioningViewModel = vmFactory.Get(); + VideoOutputSettingsCardViewModel = vmFactory.Get(); + + StackCardViewModel = vmFactory.Get(); + StackCardViewModel.AddCards( + ModelCardViewModel, + SvdImgToVidConditioningViewModel, + SamplerCardViewModel, + SeedCardViewModel, + VideoOutputSettingsCardViewModel, + BatchSizeCardViewModel + ); + } + + /// + protected override void BuildPrompt(BuildPromptEventArgs args) + { + base.BuildPrompt(args); + + var builder = args.Builder; + + builder.Connections.Seed = args.SeedOverride switch + { + { } seed => Convert.ToUInt64(seed), + _ => Convert.ToUInt64(SeedCardViewModel.Seed) + }; + + // Load models + ModelCardViewModel.ApplyStep(args); + + // Setup latent from image + var imageLoad = builder.Nodes.AddTypedNode( + new ComfyNodeBuilder.LoadImage + { + Name = builder.Nodes.GetUniqueName("ControlNet_LoadImage"), + Image = + SelectImageCardViewModel.ImageSource?.GetHashGuidFileNameCached("Inference") + ?? throw new ValidationException() + } + ); + builder.Connections.Primary = imageLoad.Output1; + builder.Connections.PrimarySize = SelectImageCardViewModel.CurrentBitmapSize; + + // Setup img2vid stuff + // Set width & height from SamplerCard + SvdImgToVidConditioningViewModel.Width = SamplerCardViewModel.Width; + SvdImgToVidConditioningViewModel.Height = SamplerCardViewModel.Height; + SvdImgToVidConditioningViewModel.ApplyStep(args); + + // Setup Sampler and Refiner if enabled + SamplerCardViewModel.ApplyStep(args); + + // Animated webp output + VideoOutputSettingsCardViewModel.ApplyStep(args); + } + + /// + protected override IEnumerable GetInputImages() + { + if (SelectImageCardViewModel.ImageSource is { } image) + { + yield return image; + } + } + + /// + protected override async Task GenerateImageImpl( + GenerateOverrides overrides, + CancellationToken cancellationToken + ) + { + if (!await CheckClientConnectedWithPrompt() || !ClientManager.IsConnected) + { + return; + } + + // If enabled, randomize the seed + var seedCard = StackCardViewModel.GetCard(); + if (overrides is not { UseCurrentSeed: true } && seedCard.IsRandomizeEnabled) + { + seedCard.GenerateNewSeed(); + } + + var batches = BatchSizeCardViewModel.BatchCount; + + var batchArgs = new List(); + + for (var i = 0; i < batches; i++) + { + var seed = seedCard.Seed + i; + + var buildPromptArgs = new BuildPromptEventArgs { Overrides = overrides, SeedOverride = seed }; + BuildPrompt(buildPromptArgs); + + var generationArgs = new ImageGenerationEventArgs + { + Client = ClientManager.Client, + Nodes = buildPromptArgs.Builder.ToNodeDictionary(), + OutputNodeNames = buildPromptArgs.Builder.Connections.OutputNodeNames.ToArray(), + Parameters = SaveStateToParameters(new GenerationParameters()), + Project = InferenceProjectDocument.FromLoadable(this), + // Only clear output images on the first batch + ClearOutputImages = i == 0 + }; + + batchArgs.Add(generationArgs); + } + + // Run batches + foreach (var args in batchArgs) + { + await RunGeneration(args, cancellationToken); + } + } + + /// + public void LoadStateFromParameters(GenerationParameters parameters) + { + SamplerCardViewModel.LoadStateFromParameters(parameters); + ModelCardViewModel.LoadStateFromParameters(parameters); + SvdImgToVidConditioningViewModel.LoadStateFromParameters(parameters); + VideoOutputSettingsCardViewModel.LoadStateFromParameters(parameters); + + SeedCardViewModel.Seed = Convert.ToInt64(parameters.Seed); + } + + /// + public GenerationParameters SaveStateToParameters(GenerationParameters parameters) + { + parameters = SamplerCardViewModel.SaveStateToParameters(parameters); + parameters = ModelCardViewModel.SaveStateToParameters(parameters); + parameters = SvdImgToVidConditioningViewModel.SaveStateToParameters(parameters); + parameters = VideoOutputSettingsCardViewModel.SaveStateToParameters(parameters); + + parameters.Seed = (ulong)SeedCardViewModel.Seed; + + return parameters; + } + + // Migration for v2 deserialization + public override void LoadStateFromJsonObject(JsonObject state, int version) + { + if (version > 2) + { + LoadStateFromJsonObject(state); + } + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs index 4531d1b0..2ca1dbd8 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceImageUpscaleViewModel.cs @@ -107,9 +107,7 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase // If upscale is enabled, add another upscale group if (IsUpscaleEnabled) { - var upscaleSize = builder.Connections.PrimarySize.WithScale( - UpscalerCardViewModel.Scale - ); + var upscaleSize = builder.Connections.PrimarySize.WithScale(UpscalerCardViewModel.Scale); // Build group builder.Connections.Primary = builder @@ -144,10 +142,7 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase } /// - protected override async Task GenerateImageImpl( - GenerateOverrides overrides, - CancellationToken cancellationToken - ) + protected override async Task GenerateImageImpl(GenerateOverrides overrides, CancellationToken cancellationToken) { if (!ClientManager.IsConnected) { @@ -174,10 +169,7 @@ public class InferenceImageUpscaleViewModel : InferenceGenerationViewModelBase Client = ClientManager.Client, Nodes = buildPromptArgs.Builder.ToNodeDictionary(), OutputNodeNames = buildPromptArgs.Builder.Connections.OutputNodeNames.ToArray(), - Parameters = new GenerationParameters - { - ModelName = UpscalerCardViewModel.SelectedUpscaler?.Name, - }, + Parameters = new GenerationParameters { ModelName = UpscalerCardViewModel.SelectedUpscaler?.Name, }, Project = InferenceProjectDocument.FromLoadable(this) }; diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs index 5336a3e1..1fed6926 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/ModelCardViewModel.cs @@ -38,45 +38,54 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager) [ObservableProperty] private bool isVaeSelectionEnabled; + [ObservableProperty] + private bool disableSettings; + + [ObservableProperty] + private bool isClipSkipEnabled; + + [NotifyDataErrorInfo] + [ObservableProperty] + [Range(1, 24)] + private int clipSkip = 1; + public IInferenceClientManager ClientManager { get; } = clientManager; /// - public void ApplyStep(ModuleApplyStepEventArgs e) + public virtual void ApplyStep(ModuleApplyStepEventArgs e) { // Load base checkpoint var baseLoader = e.Nodes.AddTypedNode( new ComfyNodeBuilder.CheckpointLoaderSimple { - Name = "CheckpointLoader", - CkptName = - SelectedModel?.RelativePath - ?? throw new ValidationException("Model not selected") + Name = "CheckpointLoader_Base", + CkptName = SelectedModel?.RelativePath ?? throw new ValidationException("Model not selected") } ); - e.Builder.Connections.BaseModel = baseLoader.Output1; - e.Builder.Connections.BaseClip = baseLoader.Output2; - e.Builder.Connections.BaseVAE = baseLoader.Output3; + e.Builder.Connections.Base.Model = baseLoader.Output1; + e.Builder.Connections.Base.Clip = baseLoader.Output2; + e.Builder.Connections.Base.VAE = baseLoader.Output3; - // Load refiner + // Load refiner if enabled if (IsRefinerSelectionEnabled && SelectedRefiner is { IsNone: false }) { var refinerLoader = e.Nodes.AddTypedNode( new ComfyNodeBuilder.CheckpointLoaderSimple { - Name = "Refiner_CheckpointLoader", + Name = "CheckpointLoader_Refiner", CkptName = SelectedRefiner?.RelativePath ?? throw new ValidationException("Refiner Model enabled but not selected") } ); - e.Builder.Connections.RefinerModel = refinerLoader.Output1; - e.Builder.Connections.RefinerClip = refinerLoader.Output2; - e.Builder.Connections.RefinerVAE = refinerLoader.Output3; + e.Builder.Connections.Refiner.Model = refinerLoader.Output1; + e.Builder.Connections.Refiner.Clip = refinerLoader.Output2; + e.Builder.Connections.Refiner.VAE = refinerLoader.Output3; } - // Load custom VAE + // Load VAE override if enabled if (IsVaeSelectionEnabled && SelectedVae is { IsNone: false, IsDefault: false }) { var vaeLoader = e.Nodes.AddTypedNode( @@ -91,6 +100,28 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager) e.Builder.Connections.PrimaryVAE = vaeLoader.Output; } + + // Clip skip all models if enabled + if (IsClipSkipEnabled) + { + foreach (var (modelName, model) in e.Builder.Connections.Models) + { + if (model.Clip is not { } modelClip) + continue; + + var clipSetLastLayer = e.Nodes.AddTypedNode( + new ComfyNodeBuilder.CLIPSetLastLayer + { + Name = $"CLIP_Skip_{modelName}", + Clip = modelClip, + // Need to convert to negative indexing from (1 to 24) to (-1 to -24) + StopAtClipLayer = -ClipSkip + } + ); + + model.Clip = clipSetLastLayer.Output; + } + } } /// @@ -102,8 +133,10 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager) SelectedModelName = SelectedModel?.RelativePath, SelectedVaeName = SelectedVae?.RelativePath, SelectedRefinerName = SelectedRefiner?.RelativePath, + ClipSkip = ClipSkip, IsVaeSelectionEnabled = IsVaeSelectionEnabled, - IsRefinerSelectionEnabled = IsRefinerSelectionEnabled + IsRefinerSelectionEnabled = IsRefinerSelectionEnabled, + IsClipSkipEnabled = IsClipSkipEnabled } ); } @@ -125,8 +158,11 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager) ? HybridModelFile.None : ClientManager.Models.FirstOrDefault(x => x.RelativePath == model.SelectedRefinerName); + ClipSkip = model.ClipSkip; + IsVaeSelectionEnabled = model.IsVaeSelectionEnabled; IsRefinerSelectionEnabled = model.IsRefinerSelectionEnabled; + IsClipSkipEnabled = model.IsClipSkipEnabled; } /// @@ -145,19 +181,14 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager) model = currentModels.FirstOrDefault( m => m.Local?.ConnectedModelInfo?.Hashes.SHA256 is { } sha256 - && sha256.StartsWith( - parameters.ModelHash, - StringComparison.InvariantCultureIgnoreCase - ) + && sha256.StartsWith(parameters.ModelHash, StringComparison.InvariantCultureIgnoreCase) ); } else { // Name matches model = currentModels.FirstOrDefault(m => m.RelativePath.EndsWith(paramsModelName)); - model ??= currentModels.FirstOrDefault( - m => m.ShortDisplayName.StartsWith(paramsModelName) - ); + model ??= currentModels.FirstOrDefault(m => m.ShortDisplayName.StartsWith(paramsModelName)); } if (model is not null) @@ -181,8 +212,10 @@ public partial class ModelCardViewModel(IInferenceClientManager clientManager) public string? SelectedModelName { get; init; } public string? SelectedRefinerName { get; init; } public string? SelectedVaeName { get; init; } + public int ClipSkip { get; init; } = 1; public bool IsVaeSelectionEnabled { get; init; } public bool IsRefinerSelectionEnabled { get; init; } + public bool IsClipSkipEnabled { get; init; } } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs index 9a6ad222..5f8c6c51 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/ControlNetModule.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; -using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models.Inference; using StabilityMatrix.Avalonia.Services; @@ -81,8 +79,8 @@ public class ControlNetModule : ModuleBase Name = e.Nodes.GetUniqueName("Refiner_ControlNetApply"), Image = imageLoad.Output1, ControlNet = controlNetLoader.Output, - Positive = e.Temp.RefinerConditioning.Value.Positive, - Negative = e.Temp.RefinerConditioning.Value.Negative, + Positive = e.Temp.RefinerConditioning.Positive, + Negative = e.Temp.RefinerConditioning.Negative, Strength = card.Strength, StartPercent = card.StartPercent, EndPercent = card.EndPercent, diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs index 4ac393b6..12093310 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/FreeUModule.cs @@ -1,7 +1,9 @@ -using StabilityMatrix.Avalonia.Models.Inference; +using System.Linq; +using StabilityMatrix.Avalonia.Models.Inference; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Models.Api.Comfy.Nodes; namespace StabilityMatrix.Avalonia.ViewModels.Inference.Modules; @@ -25,34 +27,17 @@ public class FreeUModule : ModuleBase { var card = GetCard(); - // Currently applies to both base and refiner model + // Currently applies to all models // TODO: Add option to apply to either base or refiner - if (e.Builder.Connections.BaseModel is not null) + foreach (var modelConnections in e.Builder.Connections.Models.Values.Where(m => m.Model is not null)) { - e.Builder.Connections.BaseModel = e.Nodes + modelConnections.Model = e.Nodes .AddTypedNode( new ComfyNodeBuilder.FreeU { - Name = e.Nodes.GetUniqueName("FreeU"), - Model = e.Builder.Connections.BaseModel, - B1 = card.B1, - B2 = card.B2, - S1 = card.S1, - S2 = card.S2 - } - ) - .Output; - } - - if (e.Builder.Connections.RefinerModel is not null) - { - e.Builder.Connections.RefinerModel = e.Nodes - .AddTypedNode( - new ComfyNodeBuilder.FreeU - { - Name = e.Nodes.GetUniqueName("Refiner_FreeU"), - Model = e.Builder.Connections.RefinerModel, + Name = e.Nodes.GetUniqueName($"FreeU_{modelConnections.Name}"), + Model = modelConnections.Model!, B1 = card.B1, B2 = card.B2, S1 = card.S1, diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs index 4fd98fea..588cd263 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/HiresFixModule.cs @@ -1,10 +1,7 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Input; -using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Models.Inference; using StabilityMatrix.Avalonia.Services; @@ -73,7 +70,7 @@ public partial class HiresFixModule : ModuleBase { builder.Connections.Primary = builder.Group_Upscale( builder.Nodes.GetUniqueName("HiresFix"), - builder.Connections.Primary ?? throw new ArgumentException("No Primary"), + builder.Connections.Primary.Unwrap(), builder.Connections.GetDefaultVAE(), selectedUpscaler, hiresSize.Width, @@ -99,8 +96,8 @@ public partial class HiresFixModule : ModuleBase samplerCard.SelectedScheduler?.Name ?? e.Builder.Connections.PrimaryScheduler?.Name ?? throw new ArgumentException("No PrimaryScheduler"), - Positive = builder.Connections.GetRefinerOrBaseConditioning(), - Negative = builder.Connections.GetRefinerOrBaseNegativeConditioning(), + Positive = builder.Connections.GetRefinerOrBaseConditioning().Positive, + Negative = builder.Connections.GetRefinerOrBaseConditioning().Negative, LatentImage = builder.GetPrimaryAsLatent(), Denoise = samplerCard.DenoiseStrength } diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs index bad20dba..b6973534 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/PromptCardViewModel.cs @@ -18,6 +18,7 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Helper.Cache; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes; using StabilityMatrix.Core.Models.Api.Comfy.Nodes; using StabilityMatrix.Core.Services; @@ -26,10 +27,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Inference; [View(typeof(PromptCard))] [ManagedService] [Transient] -public partial class PromptCardViewModel - : LoadableViewModelBase, - IParametersLoadableState, - IComfyStep +public partial class PromptCardViewModel : LoadableViewModelBase, IParametersLoadableState, IComfyStep { private readonly IModelIndexService modelIndexService; @@ -74,13 +72,11 @@ public partial class PromptCardViewModel /// Applies the prompt step. /// Requires: /// - /// - /// + /// - Model, Clip /// /// Provides: /// - /// - /// + /// - Conditioning /// ///
public void ApplyStep(ModuleApplyStepEventArgs e) @@ -91,90 +87,44 @@ public partial class PromptCardViewModel var negativePrompt = GetNegativePrompt(); negativePrompt.Process(); - // If need to load loras, add a group - if (positivePrompt.ExtraNetworks.Count > 0) + foreach (var modelConnections in e.Builder.Connections.Models.Values) { - var loras = positivePrompt.GetExtraNetworksAsLocalModels(modelIndexService).ToList(); - // Add group to load loras onto model and clip in series - var lorasGroup = e.Builder.Group_LoraLoadMany( - "Loras", - e.Builder.Connections.BaseModel ?? throw new ArgumentException("BaseModel is null"), - e.Builder.Connections.BaseClip ?? throw new ArgumentException("BaseClip is null"), - loras - ); - - // Set last outputs as base model and clip - e.Builder.Connections.BaseModel = lorasGroup.Output1; - e.Builder.Connections.BaseClip = lorasGroup.Output2; + if (modelConnections.Model is not { } model || modelConnections.Clip is not { } clip) + continue; - // Refiner loras - if (e.Builder.Connections.RefinerModel is not null) + // If need to load loras, add a group + if (positivePrompt.ExtraNetworks.Count > 0) { - // Add group to load loras onto refiner model and clip in series - var lorasGroupRefiner = e.Builder.Group_LoraLoadMany( - "Refiner_Loras", - e.Builder.Connections.RefinerModel - ?? throw new ArgumentException("RefinerModel is null"), - e.Builder.Connections.RefinerClip - ?? throw new ArgumentException("RefinerClip is null"), - loras - ); + var loras = positivePrompt.GetExtraNetworksAsLocalModels(modelIndexService).ToList(); - // Set last outputs as refiner model and clip - e.Builder.Connections.RefinerModel = lorasGroupRefiner.Output1; - e.Builder.Connections.RefinerClip = lorasGroupRefiner.Output2; - } - } + // Add group to load loras onto model and clip in series + var lorasGroup = e.Builder.Group_LoraLoadMany($"Loras_{modelConnections.Name}", model, clip, loras); - // Clips - var positiveClip = e.Builder.Nodes.AddTypedNode( - new ComfyNodeBuilder.CLIPTextEncode - { - Name = "PositiveCLIP", - Clip = e.Builder.Connections.BaseClip!, - Text = positivePrompt.ProcessedText - } - ); - var negativeClip = e.Builder.Nodes.AddTypedNode( - new ComfyNodeBuilder.CLIPTextEncode - { - Name = "NegativeCLIP", - Clip = e.Builder.Connections.BaseClip!, - Text = negativePrompt.ProcessedText + // Set last outputs as model and clip + modelConnections.Model = lorasGroup.Output1; + modelConnections.Clip = lorasGroup.Output2; } - ); - // Set base conditioning from Clips - e.Builder.Connections.BaseConditioning = positiveClip.Output; - e.Builder.Connections.BaseNegativeConditioning = negativeClip.Output; - - // Refiner Clips - if (e.Builder.Connections.RefinerModel is not null) - { - var positiveClipRefiner = e.Builder.Nodes.AddTypedNode( + // Clips + var positiveClip = e.Nodes.AddTypedNode( new ComfyNodeBuilder.CLIPTextEncode { - Name = "Refiner_PositiveCLIP", - Clip = - e.Builder.Connections.RefinerClip - ?? throw new ArgumentException("RefinerClip is null"), + Name = $"PositiveCLIP_{modelConnections.Name}", + Clip = e.Builder.Connections.Base.Clip!, Text = positivePrompt.ProcessedText } ); - var negativeClipRefiner = e.Builder.Nodes.AddTypedNode( + var negativeClip = e.Nodes.AddTypedNode( new ComfyNodeBuilder.CLIPTextEncode { - Name = "Refiner_NegativeCLIP", - Clip = - e.Builder.Connections.RefinerClip - ?? throw new ArgumentException("RefinerClip is null"), + Name = $"NegativeCLIP_{modelConnections.Name}", + Clip = e.Builder.Connections.Base.Clip!, Text = negativePrompt.ProcessedText } ); - // Set refiner conditioning from Clips - e.Builder.Connections.RefinerConditioning = positiveClipRefiner.Output; - e.Builder.Connections.RefinerNegativeConditioning = negativeClipRefiner.Output; + // Set conditioning from Clips + modelConnections.Conditioning = (positiveClip.Output, negativeClip.Output); } } @@ -283,11 +233,7 @@ public partial class PromptCardViewModel ``` """; - var dialog = DialogHelper.CreateMarkdownDialog( - md, - "Prompt Syntax", - TextEditorPreset.Prompt - ); + var dialog = DialogHelper.CreateMarkdownDialog(md, "Prompt Syntax", TextEditorPreset.Prompt); dialog.MinDialogWidth = 800; dialog.MaxDialogHeight = 1000; dialog.MaxDialogWidth = 1000; @@ -305,9 +251,7 @@ public partial class PromptCardViewModel } catch (PromptError e) { - await DialogHelper - .CreatePromptErrorDialog(e, prompt.RawText, modelIndexService) - .ShowAsync(); + await DialogHelper.CreatePromptErrorDialog(e, prompt.RawText, modelIndexService).ShowAsync(); return; } @@ -327,10 +271,7 @@ public partial class PromptCardViewModel builder.AppendLine($"## Networks ({networks.Count}):"); builder.AppendLine("```csharp"); builder.AppendLine( - JsonSerializer.Serialize( - networks, - new JsonSerializerOptions() { WriteIndented = true, } - ) + JsonSerializer.Serialize(networks, new JsonSerializerOptions() { WriteIndented = true, }) ); builder.AppendLine("```"); } @@ -378,11 +319,7 @@ public partial class PromptCardViewModel public override JsonObject SaveStateToJsonObject() { return SerializeModel( - new PromptCardModel - { - Prompt = PromptDocument.Text, - NegativePrompt = NegativePromptDocument.Text - } + new PromptCardModel { Prompt = PromptDocument.Text, NegativePrompt = NegativePromptDocument.Text } ); } @@ -405,10 +342,6 @@ public partial class PromptCardViewModel /// public GenerationParameters SaveStateToParameters(GenerationParameters parameters) { - return parameters with - { - PositivePrompt = PromptDocument.Text, - NegativePrompt = NegativePromptDocument.Text - }; + return parameters with { PositivePrompt = PromptDocument.Text, NegativePrompt = NegativePromptDocument.Text }; } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs index a0d300a1..96b9de36 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/SamplerCardViewModel.cs @@ -12,6 +12,7 @@ using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Inference.Modules; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Api.Comfy; @@ -122,14 +123,8 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo } // Provide temp values - e.Temp.Conditioning = ( - e.Builder.Connections.BaseConditioning!, - e.Builder.Connections.BaseNegativeConditioning! - ); - e.Temp.RefinerConditioning = ( - e.Builder.Connections.RefinerConditioning!, - e.Builder.Connections.RefinerNegativeConditioning! - ); + e.Temp.Conditioning = e.Builder.Connections.Base.Conditioning; + e.Temp.RefinerConditioning = e.Builder.Connections.Refiner.Conditioning; // Apply steps from our addons ApplyAddonSteps(e); @@ -153,16 +148,17 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo var primaryLatent = e.Builder.GetPrimaryAsLatent(); // Set primary sampler and scheduler - e.Builder.Connections.PrimarySampler = - SelectedSampler ?? throw new ValidationException("Sampler not selected"); - e.Builder.Connections.PrimaryScheduler = - SelectedScheduler ?? throw new ValidationException("Scheduler not selected"); + var primarySampler = SelectedSampler ?? throw new ValidationException("Sampler not selected"); + e.Builder.Connections.PrimarySampler = primarySampler; + + var primaryScheduler = SelectedScheduler ?? throw new ValidationException("Scheduler not selected"); + e.Builder.Connections.PrimaryScheduler = primaryScheduler; // Use custom sampler if SDTurbo scheduler is selected if (e.Builder.Connections.PrimaryScheduler == ComfyScheduler.SDTurbo) { // Error if using refiner - if (e.Builder.Connections.RefinerModel is not null) + if (e.Builder.Connections.Refiner.Model is not null) { throw new ValidationException("SDTurbo Scheduler cannot be used with Refiner Model"); } @@ -179,7 +175,7 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo new ComfyNodeBuilder.SDTurboScheduler { Name = "SDTurboScheduler", - Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"), + Model = e.Builder.Connections.Base.Model.Unwrap(), Steps = Steps, Denoise = DenoiseStrength } @@ -189,7 +185,7 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo new ComfyNodeBuilder.SamplerCustom { Name = "Sampler", - Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"), + Model = e.Builder.Connections.Base.Model, AddNoise = true, NoiseSeed = e.Builder.Connections.Seed, Cfg = CfgScale, @@ -207,21 +203,23 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo } // Use KSampler if no refiner, otherwise need KSamplerAdvanced - if (e.Builder.Connections.RefinerModel is null) + if (e.Builder.Connections.Refiner.Model is null) { + var baseConditioning = e.Builder.Connections.Base.Conditioning.Unwrap(); + // No refiner var sampler = e.Nodes.AddTypedNode( new ComfyNodeBuilder.KSampler { Name = "Sampler", - Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"), + Model = e.Builder.Connections.Base.Model.Unwrap(), Seed = e.Builder.Connections.Seed, - SamplerName = e.Builder.Connections.PrimarySampler?.Name!, - Scheduler = e.Builder.Connections.PrimaryScheduler?.Name!, + SamplerName = primarySampler.Name, + Scheduler = primaryScheduler.Name, Steps = Steps, Cfg = CfgScale, - Positive = e.Temp.Conditioning?.Positive!, - Negative = e.Temp.Conditioning?.Negative!, + Positive = baseConditioning.Positive, + Negative = baseConditioning.Negative, LatentImage = primaryLatent, Denoise = DenoiseStrength, } @@ -231,20 +229,23 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo } else { + var baseConditioning = e.Builder.Connections.Base.Conditioning.Unwrap(); + var refinerConditioning = e.Builder.Connections.Refiner.Conditioning.Unwrap(); + // Advanced base sampler for refiner var sampler = e.Nodes.AddTypedNode( new ComfyNodeBuilder.KSamplerAdvanced { Name = "Sampler", - Model = e.Builder.Connections.BaseModel ?? throw new ArgumentException("No BaseModel"), + Model = e.Builder.Connections.Base.Model.Unwrap(), AddNoise = true, NoiseSeed = e.Builder.Connections.Seed, Steps = TotalSteps, Cfg = CfgScale, - SamplerName = e.Builder.Connections.PrimarySampler?.Name!, - Scheduler = e.Builder.Connections.PrimaryScheduler?.Name!, - Positive = e.Temp.Conditioning?.Positive!, - Negative = e.Temp.Conditioning?.Negative!, + SamplerName = primarySampler.Name, + Scheduler = primaryScheduler.Name, + Positive = baseConditioning.Positive, + Negative = baseConditioning.Negative, LatentImage = primaryLatent, StartAtStep = 0, EndAtStep = Steps, @@ -256,17 +257,16 @@ public partial class SamplerCardViewModel : LoadableViewModelBase, IParametersLo var refinerSampler = e.Nodes.AddTypedNode( new ComfyNodeBuilder.KSamplerAdvanced { - Name = "Refiner_Sampler", - Model = - e.Builder.Connections.RefinerModel ?? throw new ArgumentException("No RefinerModel"), + Name = "Sampler_Refiner", + Model = e.Builder.Connections.Refiner.Model, AddNoise = false, NoiseSeed = e.Builder.Connections.Seed, Steps = TotalSteps, Cfg = CfgScale, - SamplerName = e.Builder.Connections.PrimarySampler?.Name!, - Scheduler = e.Builder.Connections.PrimaryScheduler?.Name!, - Positive = e.Temp.RefinerConditioning?.Positive!, - Negative = e.Temp.RefinerConditioning?.Negative!, + SamplerName = primarySampler.Name, + Scheduler = primaryScheduler.Name, + Positive = refinerConditioning.Positive, + Negative = refinerConditioning.Negative, // Connect to previous sampler LatentImage = sampler.Output, StartAtStep = Steps, diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs new file mode 100644 index 00000000..2bbf8021 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/ImgToVidModelCardViewModel.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Api.Comfy.Nodes; + +namespace StabilityMatrix.Avalonia.ViewModels.Inference.Video; + +[View(typeof(ModelCard))] +[ManagedService] +[Transient] +public class ImgToVidModelCardViewModel : ModelCardViewModel +{ + public ImgToVidModelCardViewModel(IInferenceClientManager clientManager) + : base(clientManager) + { + DisableSettings = true; + } + + public override void ApplyStep(ModuleApplyStepEventArgs e) + { + var imgToVidLoader = e.Nodes.AddTypedNode( + new ComfyNodeBuilder.ImageOnlyCheckpointLoader + { + Name = "ImageOnlyCheckpointLoader", + CkptName = SelectedModel?.RelativePath ?? throw new ValidationException("Model not selected") + } + ); + + e.Builder.Connections.Base.Model = imgToVidLoader.Output1; + e.Builder.Connections.BaseClipVision = imgToVidLoader.Output2; + e.Builder.Connections.Base.VAE = imgToVidLoader.Output3; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs new file mode 100644 index 00000000..1bb70b26 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/SvdImgToVidConditioningViewModel.cs @@ -0,0 +1,104 @@ +using System.ComponentModel.DataAnnotations; +using CommunityToolkit.Mvvm.ComponentModel; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api.Comfy.Nodes; +using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes; + +namespace StabilityMatrix.Avalonia.ViewModels.Inference.Video; + +[View(typeof(VideoGenerationSettingsCard))] +[ManagedService] +[Transient] +public partial class SvdImgToVidConditioningViewModel + : LoadableViewModelBase, + IParametersLoadableState, + IComfyStep +{ + [ObservableProperty] + private int width = 1024; + + [ObservableProperty] + private int height = 576; + + [ObservableProperty] + private int numFrames = 14; + + [ObservableProperty] + private int motionBucketId = 127; + + [ObservableProperty] + private int fps = 6; + + [ObservableProperty] + private double augmentationLevel; + + [ObservableProperty] + private double minCfg = 1.0d; + + public void LoadStateFromParameters(GenerationParameters parameters) + { + Width = parameters.Width; + Height = parameters.Height; + NumFrames = parameters.FrameCount; + MotionBucketId = parameters.MotionBucketId; + Fps = parameters.Fps; + AugmentationLevel = parameters.AugmentationLevel; + MinCfg = parameters.MinCfg; + } + + public GenerationParameters SaveStateToParameters(GenerationParameters parameters) + { + return parameters with + { + FrameCount = NumFrames, + MotionBucketId = MotionBucketId, + Fps = Fps, + AugmentationLevel = AugmentationLevel, + MinCfg = MinCfg, + }; + } + + public void ApplyStep(ModuleApplyStepEventArgs e) + { + // do VideoLinearCFGGuidance stuff first + var cfgGuidanceNode = e.Nodes.AddTypedNode( + new ComfyNodeBuilder.VideoLinearCFGGuidance + { + Name = e.Nodes.GetUniqueName("LinearCfgGuidance"), + Model = + e.Builder.Connections.Base.Model ?? throw new ValidationException("Model not selected"), + MinCfg = MinCfg + } + ); + + e.Builder.Connections.Base.Model = cfgGuidanceNode.Output; + + // then do the SVD stuff + var svdImgToVidConditioningNode = e.Nodes.AddTypedNode( + new ComfyNodeBuilder.SVD_img2vid_Conditioning + { + ClipVision = e.Builder.Connections.BaseClipVision!, + InitImage = e.Builder.GetPrimaryAsImage(), + Vae = e.Builder.Connections.Base.VAE!, + Name = e.Nodes.GetUniqueName("SvdImgToVidConditioning"), + Width = Width, + Height = Height, + VideoFrames = NumFrames, + MotionBucketId = MotionBucketId, + Fps = Fps, + AugmentationLevel = AugmentationLevel + } + ); + + e.Builder.Connections.Base.Conditioning = new ConditioningConnections( + svdImgToVidConditioningNode.Output1, + svdImgToVidConditioningNode.Output2 + ); + e.Builder.Connections.Primary = svdImgToVidConditioningNode.Output3; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs new file mode 100644 index 00000000..d44f469a --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Video/VideoOutputSettingsCardViewModel.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api.Comfy.Nodes; + +namespace StabilityMatrix.Avalonia.ViewModels.Inference.Video; + +[View(typeof(VideoOutputSettingsCard))] +[ManagedService] +[Transient] +public partial class VideoOutputSettingsCardViewModel + : LoadableViewModelBase, + IParametersLoadableState, + IComfyStep +{ + [ObservableProperty] + private double fps = 6; + + [ObservableProperty] + private bool lossless = true; + + [ObservableProperty] + private int quality = 85; + + [ObservableProperty] + private VideoOutputMethod selectedMethod = VideoOutputMethod.Default; + + [ObservableProperty] + private List availableMethods = Enum.GetValues().ToList(); + + public void LoadStateFromParameters(GenerationParameters parameters) + { + Fps = parameters.OutputFps; + Lossless = parameters.Lossless; + Quality = parameters.VideoQuality; + + if (string.IsNullOrWhiteSpace(parameters.VideoOutputMethod)) + return; + + SelectedMethod = Enum.TryParse(parameters.VideoOutputMethod, true, out var method) + ? method + : VideoOutputMethod.Default; + } + + public GenerationParameters SaveStateToParameters(GenerationParameters parameters) + { + return parameters with + { + OutputFps = Fps, + Lossless = Lossless, + VideoQuality = Quality, + VideoOutputMethod = SelectedMethod.ToString(), + }; + } + + public void ApplyStep(ModuleApplyStepEventArgs e) + { + if (e.Builder.Connections.Primary is null) + throw new ArgumentException("No Primary"); + + var image = e.Builder.Connections.Primary.Match( + _ => + e.Builder.GetPrimaryAsImage( + e.Builder.Connections.PrimaryVAE + ?? e.Builder.Connections.Refiner.VAE + ?? e.Builder.Connections.Base.VAE + ?? throw new ArgumentException("No Primary, Refiner, or Base VAE") + ), + image => image + ); + + var outputStep = e.Nodes.AddTypedNode( + new ComfyNodeBuilder.SaveAnimatedWEBP + { + Name = e.Nodes.GetUniqueName("SaveAnimatedWEBP"), + Images = image, + FilenamePrefix = "InferenceVideo", + Fps = Fps, + Lossless = Lossless, + Quality = Quality, + Method = SelectedMethod.ToString().ToLowerInvariant() + } + ); + + e.Builder.Connections.OutputNodes.Add(outputStep); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs index e3966c1f..3662f452 100644 --- a/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/InferenceViewModel.cs @@ -53,7 +53,8 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable private readonly ILiteDbContext liteDbContext; public override string Title => "Inference"; - public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.AppGeneric, IsFilled = true }; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.AppGeneric, IsFilled = true }; public RefreshBadgeViewModel ConnectionBadge { get; } = new() @@ -110,6 +111,8 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable // "Send to Inference" EventManager.Instance.InferenceTextToImageRequested += OnInferenceTextToImageRequested; EventManager.Instance.InferenceUpscaleRequested += OnInferenceUpscaleRequested; + EventManager.Instance.InferenceImageToImageRequested += OnInferenceImageToImageRequested; + EventManager.Instance.InferenceImageToVideoRequested += OnInferenceImageToVideoRequested; MenuSaveAsCommand.WithConditionalNotificationErrorHandler(notificationService); MenuOpenProjectCommand.WithConditionalNotificationErrorHandler(notificationService); @@ -126,47 +129,43 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable IDisposable? onStartupComplete = null; - Dispatcher - .UIThread - .Post(() => + Dispatcher.UIThread.Post(() => + { + if (e.CurrentPackagePair?.BasePackage is ComfyUI package) { - if (e.CurrentPackagePair?.BasePackage is ComfyUI package) - { - IsWaitingForConnection = true; - onStartupComplete = Observable - .FromEventPattern(package, nameof(package.StartupComplete)) - .Take(1) - .Subscribe(_ => + IsWaitingForConnection = true; + onStartupComplete = Observable + .FromEventPattern(package, nameof(package.StartupComplete)) + .Take(1) + .Subscribe(_ => + { + Dispatcher.UIThread.Post(() => { - Dispatcher - .UIThread - .Post(() => - { - if (ConnectCommand.CanExecute(null)) - { - Logger.Trace("On package launch - starting connection"); - ConnectCommand.Execute(null); - } - IsWaitingForConnection = false; - }); + if (ConnectCommand.CanExecute(null)) + { + Logger.Trace("On package launch - starting connection"); + ConnectCommand.Execute(null); + } + IsWaitingForConnection = false; }); - } - else + }); + } + else + { + // Cancel any pending connection + if (ConnectCancelCommand.CanExecute(null)) { - // Cancel any pending connection - if (ConnectCancelCommand.CanExecute(null)) - { - ConnectCancelCommand.Execute(null); - } - onStartupComplete?.Dispose(); - onStartupComplete = null; - IsWaitingForConnection = false; - - // Disconnect - Logger.Trace("On package close - disconnecting"); - DisconnectCommand.Execute(null); + ConnectCancelCommand.Execute(null); } - }); + onStartupComplete?.Dispose(); + onStartupComplete = null; + IsWaitingForConnection = false; + + // Disconnect + Logger.Trace("On package close - disconnecting"); + DisconnectCommand.Execute(null); + } + }); } public override void OnLoaded() @@ -216,9 +215,14 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable ); // Set not open - await liteDbContext - .InferenceProjects - .UpdateAsync(project with { IsOpen = false, IsSelected = false, CurrentTabIndex = -1 }); + await liteDbContext.InferenceProjects.UpdateAsync( + project with + { + IsOpen = false, + IsSelected = false, + CurrentTabIndex = -1 + } + ); } } } @@ -249,6 +253,16 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable Dispatcher.UIThread.Post(() => AddUpscalerTabFromImage(e).SafeFireAndForget()); } + private void OnInferenceImageToImageRequested(object? sender, LocalImageFile e) + { + Dispatcher.UIThread.Post(() => AddImageToImageFromImage(e).SafeFireAndForget()); + } + + private void OnInferenceImageToVideoRequested(object? sender, LocalImageFile e) + { + Dispatcher.UIThread.Post(() => AddImageToVideoFromImage(e).SafeFireAndForget()); + } + /// /// Update the database with current tabs /// @@ -272,7 +286,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable entry.IsOpen = tab == SelectedTab; entry.CurrentTabIndex = i; - Logger.Trace("SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}", tab.TabTitle, entry); + Logger.Trace( + "SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}", + tab.TabTitle, + entry + ); await liteDbContext.InferenceProjects.UpsertAsync(entry); } } @@ -287,7 +305,9 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable return; } - var entry = await liteDbContext.InferenceProjects.FindOneAsync(p => p.FilePath == projectFile.ToString()); + var entry = await liteDbContext.InferenceProjects.FindOneAsync( + p => p.FilePath == projectFile.ToString() + ); // Create if not found entry ??= new InferenceProjectEntry { Id = Guid.NewGuid(), FilePath = projectFile.ToString() }; @@ -295,7 +315,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable entry.IsOpen = tab == SelectedTab; entry.CurrentTabIndex = Tabs.IndexOf(tab); - Logger.Trace("SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}", tab.TabTitle, entry); + Logger.Trace( + "SyncTabStatesWithDatabase updated entry for tab '{Title}': {@Entry}", + tab.TabTitle, + entry + ); await liteDbContext.InferenceProjects.UpsertAsync(entry); } @@ -408,7 +432,10 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable return; } - await notificationService.TryAsync(ClientManager.CloseAsync(), "Could not disconnect from ComfyUI backend"); + await notificationService.TryAsync( + ClientManager.CloseAsync(), + "Could not disconnect from ComfyUI backend" + ); } /// @@ -464,7 +491,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable await using var stream = await result.OpenWriteAsync(); stream.SetLength(0); // Overwrite fully - await JsonSerializer.SerializeAsync(stream, document, new JsonSerializerOptions { WriteIndented = true }); + await JsonSerializer.SerializeAsync( + stream, + document, + new JsonSerializerOptions { WriteIndented = true } + ); } catch (Exception e) { @@ -512,7 +543,11 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable await using var stream = projectFile.Info.OpenWrite(); stream.SetLength(0); // Overwrite fully - await JsonSerializer.SerializeAsync(stream, document, new JsonSerializerOptions { WriteIndented = true }); + await JsonSerializer.SerializeAsync( + stream, + document, + new JsonSerializerOptions { WriteIndented = true } + ); } catch (Exception e) { @@ -624,6 +659,46 @@ public partial class InferenceViewModel : PageViewModelBase, IAsyncDisposable await SyncTabStatesWithDatabase(); } + private async Task AddImageToImageFromImage(LocalImageFile imageFile) + { + var imgToImgVm = vmFactory.Get(); + + if (!imageFile.FileName.EndsWith("webp")) + { + imgToImgVm.SelectImageCardViewModel.ImageSource = new ImageSource(imageFile.AbsolutePath); + } + + if (imageFile.GenerationParameters != null) + { + imgToImgVm.LoadStateFromParameters(imageFile.GenerationParameters); + } + + Tabs.Add(imgToImgVm); + SelectedTab = imgToImgVm; + + await SyncTabStatesWithDatabase(); + } + + private async Task AddImageToVideoFromImage(LocalImageFile imageFile) + { + var imgToVidVm = vmFactory.Get(); + + if (imageFile.GenerationParameters != null && imageFile.FileName.EndsWith("webp")) + { + imgToVidVm.LoadStateFromParameters(imageFile.GenerationParameters); + } + + if (!imageFile.FileName.EndsWith("webp")) + { + imgToVidVm.SelectImageCardViewModel.ImageSource = new ImageSource(imageFile.AbsolutePath); + } + + Tabs.Add(imgToVidVm); + SelectedTab = imgToVidVm; + + await SyncTabStatesWithDatabase(); + } + /// /// Menu "Open Project" command. /// diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs index 842df99e..c546d1ee 100644 --- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reactive.Linq; using System.Threading.Tasks; using AsyncAwaitBestPractices; using Avalonia.Controls; @@ -10,6 +11,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using FluentAvalonia.UI.Controls; +using KGySoft.CoreLibraries; using NLog; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Services; @@ -112,10 +114,7 @@ public partial class MainWindowViewModel : ViewModelBase var startupTime = CodeTimer.FormatTime(Program.StartupTimer.Elapsed); Logger.Info($"App started ({startupTime})"); - if ( - Program.Args.DebugOneClickInstall - || settingsManager.Settings.InstalledPackages.Count == 0 - ) + if (Program.Args.DebugOneClickInstall || settingsManager.Settings.InstalledPackages.Count == 0) { var viewModel = dialogFactory.Get(); var dialog = new BetterContentDialog @@ -148,8 +147,8 @@ public partial class MainWindowViewModel : ViewModelBase .Where(p => p.GetType().GetCustomAttributes(typeof(PreloadAttribute), true).Any()) ) { - Dispatcher.UIThread - .InvokeAsync( + Dispatcher + .UIThread.InvokeAsync( async () => { var stopwatch = Stopwatch.StartNew(); diff --git a/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs new file mode 100644 index 00000000..0cd0e689 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/NewPackageManagerViewModel.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using DynamicData; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.ViewModels.PackageManager; +using StabilityMatrix.Avalonia.Views; +using StabilityMatrix.Core.Attributes; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels; + +[View(typeof(NewPackageManagerPage))] +[Singleton] +public partial class NewPackageManagerViewModel : PageViewModelBase +{ + public override string Title => "Packages"; + public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true }; + + public IReadOnlyList SubPages { get; } + + [ObservableProperty] + private ObservableCollection currentPagePath = []; + + [ObservableProperty] + private PageViewModelBase? currentPage; + + public NewPackageManagerViewModel(ServiceManager vmFactory) + { + SubPages = new PageViewModelBase[] + { + vmFactory.Get(), + vmFactory.Get(), + }; + + CurrentPagePath.AddRange(SubPages); + + CurrentPage = SubPages[0]; + } + + partial void OnCurrentPageChanged(PageViewModelBase? value) + { + if (value is null) + { + return; + } + + if (value is PackageManagerViewModel) + { + CurrentPagePath.Clear(); + CurrentPagePath.Add(value); + } + else if (value is PackageInstallDetailViewModel) + { + CurrentPagePath.Add(value); + } + else + { + CurrentPagePath.Clear(); + CurrentPagePath.AddRange(new[] { SubPages[0], value }); + } + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs index b49ff6d1..f1186751 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -93,6 +93,8 @@ public partial class OutputsPageViewModel : PageViewModelBase ? Resources.Label_OneImageSelected : string.Format(Resources.Label_NumImagesSelected, NumItemsSelected); + private string[] allowedExtensions = [".png", ".webp"]; + public OutputsPageViewModel( ISettingsManager settingsManager, IPackageFactory packageFactory, @@ -313,6 +315,18 @@ public partial class OutputsPageViewModel : PageViewModelBase EventManager.Instance.OnInferenceUpscaleRequested(vm.ImageFile); } + public void SendToImageToImage(OutputImageViewModel vm) + { + navigationService.NavigateTo(); + EventManager.Instance.OnInferenceImageToImageRequested(vm.ImageFile); + } + + public void SendToImageToVideo(OutputImageViewModel vm) + { + navigationService.NavigateTo(); + EventManager.Instance.OnInferenceImageToVideoRequested(vm.ImageFile); + } + public void ClearSelection() { foreach (var output in Outputs) @@ -434,11 +448,14 @@ public partial class OutputsPageViewModel : PageViewModelBase var directory = category.Tag.ToString(); - foreach (var path in Directory.EnumerateFiles(directory, "*.png", SearchOption.AllDirectories)) + foreach (var path in Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories)) { try { var file = new FilePath(path); + if (!allowedExtensions.Contains(file.Extension)) + continue; + var newPath = settingsManager.ConsolidatedImagesDirectory + file.Name; if (file.FullPath == newPath) continue; @@ -496,7 +513,8 @@ public partial class OutputsPageViewModel : PageViewModelBase } var files = Directory - .EnumerateFiles(directory, "*.png", SearchOption.AllDirectories) + .EnumerateFiles(directory, "*.*", SearchOption.AllDirectories) + .Where(path => allowedExtensions.Contains(new FilePath(path).Extension)) .Select(file => LocalImageFile.FromPath(file)) .ToList(); diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs index e6d1c6a8..9ce70775 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs @@ -97,7 +97,10 @@ public partial class PackageCardViewModel : ProgressViewModel if (string.IsNullOrWhiteSpace(value?.PackageName)) return; - if (value.PackageName == UnknownPackage.Key || packageFactory.FindPackageByName(value.PackageName) is null) + if ( + value.PackageName == UnknownPackage.Key + || packageFactory.FindPackageByName(value.PackageName) is null + ) { IsUnknownPackage = true; CardImageSource = ""; @@ -124,7 +127,11 @@ public partial class PackageCardViewModel : ProgressViewModel if (Design.IsDesignMode || !settingsManager.IsLibraryDirSet || Package is not { } currentPackage) return; - if (packageFactory.FindPackageByName(currentPackage.PackageName) is { } basePackage and not UnknownPackage) + if ( + packageFactory.FindPackageByName(currentPackage.PackageName) + is { } basePackage + and not UnknownPackage + ) { // Migrate old packages with null preferred shared folder method currentPackage.PreferredSharedFolderMethod ??= basePackage.RecommendedSharedFolderMethod; @@ -185,11 +192,18 @@ public partial class PackageCardViewModel : ProgressViewModel var packagePath = new DirectoryPath(settingsManager.LibraryDir, Package.LibraryPath); var deleteTask = packagePath.DeleteVerboseAsync(logger); - var taskResult = await notificationService.TryAsync(deleteTask, Resources.Text_SomeFilesCouldNotBeDeleted); + var taskResult = await notificationService.TryAsync( + deleteTask, + Resources.Text_SomeFilesCouldNotBeDeleted + ); if (taskResult.IsSuccessful) { notificationService.Show( - new Notification(Resources.Label_PackageUninstalled, Package.DisplayName, NotificationType.Success) + new Notification( + Resources.Label_PackageUninstalled, + Package.DisplayName, + NotificationType.Success + ) ); if (!IsUnknownPackage) @@ -254,7 +268,12 @@ public partial class PackageCardViewModel : ProgressViewModel versionOptions.CommitHash = latest.Sha; } - var updatePackageStep = new UpdatePackageStep(settingsManager, Package, versionOptions, basePackage); + var updatePackageStep = new UpdatePackageStep( + settingsManager, + Package, + versionOptions, + basePackage + ); var steps = new List { updatePackageStep }; EventManager.Instance.OnPackageInstallProgressAdded(runner); @@ -381,7 +400,8 @@ public partial class PackageCardViewModel : ProgressViewModel if (basePackage == null) return false; - var canCheckUpdate = Package.LastUpdateCheck == null || Package.LastUpdateCheck < DateTime.Now.AddMinutes(-15); + var canCheckUpdate = + Package.LastUpdateCheck == null || Package.LastUpdateCheck < DateTime.Now.AddMinutes(-15); if (!canCheckUpdate) { diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs new file mode 100644 index 00000000..e38770ac --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallBrowserViewModel.cs @@ -0,0 +1,143 @@ +using System; +using System.Reactive.Linq; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using DynamicData; +using DynamicData.Alias; +using DynamicData.Binding; +using FluentAvalonia.UI.Controls; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views.PackageManager; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Factory; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.PackageManager; + +[View(typeof(PackageInstallBrowserView))] +[Transient, ManagedService] +public partial class PackageInstallBrowserViewModel : PageViewModelBase +{ + private readonly INavigationService packageNavigationService; + private readonly ISettingsManager settingsManager; + private readonly INotificationService notificationService; + private readonly ILogger logger; + private readonly IPyRunner pyRunner; + private readonly IPrerequisiteHelper prerequisiteHelper; + + [ObservableProperty] + private bool showIncompatiblePackages; + + [ObservableProperty] + private string searchFilter = string.Empty; + + private SourceCache packageSource = new(p => p.GithubUrl); + + public IObservableCollection InferencePackages { get; } = + new ObservableCollectionExtended(); + + public IObservableCollection TrainingPackages { get; } = + new ObservableCollectionExtended(); + + public PackageInstallBrowserViewModel( + IPackageFactory packageFactory, + INavigationService packageNavigationService, + ISettingsManager settingsManager, + INotificationService notificationService, + ILogger logger, + IPyRunner pyRunner, + IPrerequisiteHelper prerequisiteHelper + ) + { + this.packageNavigationService = packageNavigationService; + this.settingsManager = settingsManager; + this.notificationService = notificationService; + this.logger = logger; + this.pyRunner = pyRunner; + this.prerequisiteHelper = prerequisiteHelper; + + var incompatiblePredicate = this.WhenPropertyChanged(vm => vm.ShowIncompatiblePackages) + .Select(_ => new Func(p => p.IsCompatible || ShowIncompatiblePackages)) + .AsObservable(); + + var searchPredicate = this.WhenPropertyChanged(vm => vm.SearchFilter) + .Select( + _ => + new Func( + p => p.DisplayName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase) + ) + ) + .AsObservable(); + + packageSource + .Connect() + .DeferUntilLoaded() + .Filter(incompatiblePredicate) + .Filter(searchPredicate) + .Where(p => p is { PackageType: PackageType.SdInference }) + .Sort( + SortExpressionComparer + .Ascending(p => p.InstallerSortOrder) + .ThenByAscending(p => p.DisplayName) + ) + .Bind(InferencePackages) + .Subscribe(); + + packageSource + .Connect() + .DeferUntilLoaded() + .Filter(incompatiblePredicate) + .Filter(searchPredicate) + .Where(p => p is { PackageType: PackageType.SdTraining }) + .Sort( + SortExpressionComparer + .Ascending(p => p.InstallerSortOrder) + .ThenByAscending(p => p.DisplayName) + ) + .Bind(TrainingPackages) + .Subscribe(); + + packageSource.EditDiff( + packageFactory.GetAllAvailablePackages(), + (a, b) => a.GithubUrl == b.GithubUrl + ); + } + + public override string Title => "Add Package"; + public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Add }; + + public void OnPackageSelected(BasePackage? package) + { + if (package is null) + { + return; + } + + var vm = new PackageInstallDetailViewModel( + package, + settingsManager, + notificationService, + logger, + pyRunner, + prerequisiteHelper, + packageNavigationService + ); + + Dispatcher.UIThread.Post( + () => packageNavigationService.NavigateTo(vm, BetterSlideNavigationTransition.PageSlideFromRight), + DispatcherPriority.Send + ); + } + + public void ClearSearchQuery() + { + SearchFilter = string.Empty; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs new file mode 100644 index 00000000..3c940d25 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls; +using Avalonia.Controls.Notifications; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Extensions; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Database; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using PackageInstallDetailView = StabilityMatrix.Avalonia.Views.PackageManager.PackageInstallDetailView; +using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels.PackageManager; + +[View(typeof(PackageInstallDetailView))] +public partial class PackageInstallDetailViewModel( + BasePackage package, + ISettingsManager settingsManager, + INotificationService notificationService, + ILogger logger, + IPyRunner pyRunner, + IPrerequisiteHelper prerequisiteHelper, + INavigationService packageNavigationService +) : PageViewModelBase +{ + public BasePackage SelectedPackage { get; } = package; + public override string Title { get; } = package.DisplayName; + public override IconSource IconSource => new SymbolIconSource(); + + public string FullInstallPath => Path.Combine(settingsManager.LibraryDir, "Packages", InstallName); + public bool ShowReleaseMode => SelectedPackage.ShouldIgnoreReleases == false; + + public string ReleaseLabelText => IsReleaseMode ? Resources.Label_Version : Resources.Label_Branch; + + public bool ShowTorchVersionOptions => SelectedTorchVersion != TorchVersion.None; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FullInstallPath))] + private string installName = package.DisplayName; + + [ObservableProperty] + private bool showDuplicateWarning; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ReleaseLabelText))] + private bool isReleaseMode; + + [ObservableProperty] + private IEnumerable availableVersions = new List(); + + [ObservableProperty] + private PackageVersion? selectedVersion; + + [ObservableProperty] + private SharedFolderMethod selectedSharedFolderMethod; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowTorchVersionOptions))] + private TorchVersion selectedTorchVersion; + + [ObservableProperty] + private ObservableCollection? availableCommits; + + [ObservableProperty] + private GitCommit? selectedCommit; + + private PackageVersionOptions? allOptions; + + public override async Task OnLoadedAsync() + { + if (Design.IsDesignMode) + return; + + OnInstallNameChanged(InstallName); + + allOptions = await SelectedPackage.GetAllVersionOptions(); + if (ShowReleaseMode) + { + IsReleaseMode = true; + } + else + { + UpdateVersions(); + await UpdateCommits(SelectedPackage.MainBranch); + } + + SelectedTorchVersion = SelectedPackage.GetRecommendedTorchVersion(); + SelectedSharedFolderMethod = SelectedPackage.RecommendedSharedFolderMethod; + } + + [RelayCommand] + private async Task Install() + { + if (string.IsNullOrWhiteSpace(InstallName)) + { + notificationService.Show( + new Notification( + "Package name is empty", + "Please enter a name for the package", + NotificationType.Error + ) + ); + return; + } + + var setPackageInstallingStep = new SetPackageInstallingStep(settingsManager, InstallName); + + var installLocation = Path.Combine(settingsManager.LibraryDir, "Packages", InstallName); + if (Directory.Exists(installLocation)) + { + var installPath = new DirectoryPath(installLocation); + await installPath.DeleteVerboseAsync(logger); + } + + var prereqStep = new SetupPrerequisitesStep(prerequisiteHelper, pyRunner); + + var downloadOptions = new DownloadPackageVersionOptions(); + var installedVersion = new InstalledPackageVersion(); + if (IsReleaseMode) + { + downloadOptions.VersionTag = + SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null"); + downloadOptions.IsLatest = AvailableVersions?.First().TagName == downloadOptions.VersionTag; + downloadOptions.IsPrerelease = SelectedVersion.IsPrerelease; + + installedVersion.InstalledReleaseVersion = downloadOptions.VersionTag; + installedVersion.IsPrerelease = SelectedVersion.IsPrerelease; + } + else + { + downloadOptions.CommitHash = + SelectedCommit?.Sha ?? throw new NullReferenceException("Selected commit is null"); + downloadOptions.BranchName = + SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null"); + downloadOptions.IsLatest = AvailableCommits?.First().Sha == SelectedCommit.Sha; + + installedVersion.InstalledBranch = + SelectedVersion?.TagName ?? throw new NullReferenceException("Selected version is null"); + installedVersion.InstalledCommitSha = downloadOptions.CommitHash; + } + + var downloadStep = new DownloadPackageVersionStep(SelectedPackage, installLocation, downloadOptions); + var installStep = new InstallPackageStep( + SelectedPackage, + SelectedTorchVersion, + SelectedSharedFolderMethod, + downloadOptions, + installLocation + ); + + var setupModelFoldersStep = new SetupModelFoldersStep( + SelectedPackage, + SelectedSharedFolderMethod, + installLocation + ); + + var package = new InstalledPackage + { + DisplayName = InstallName, + LibraryPath = Path.Combine("Packages", InstallName), + Id = Guid.NewGuid(), + PackageName = SelectedPackage.Name, + Version = installedVersion, + LaunchCommand = SelectedPackage.LaunchCommand, + LastUpdateCheck = DateTimeOffset.Now, + PreferredTorchVersion = SelectedTorchVersion, + PreferredSharedFolderMethod = SelectedSharedFolderMethod + }; + + var addInstalledPackageStep = new AddInstalledPackageStep(settingsManager, package); + + var steps = new List + { + setPackageInstallingStep, + prereqStep, + downloadStep, + installStep, + setupModelFoldersStep, + addInstalledPackageStep + }; + + var runner = new PackageModificationRunner { ShowDialogOnStart = true }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + await runner.ExecuteSteps(steps.ToList()); + + if (!runner.Failed) + { + EventManager.Instance.OnInstalledPackagesChanged(); + notificationService.Show( + "Package Install Complete", + $"{InstallName} installed successfully", + NotificationType.Success + ); + } + } + + private void UpdateVersions() + { + AvailableVersions = + IsReleaseMode && ShowReleaseMode ? allOptions.AvailableVersions : allOptions.AvailableBranches; + + SelectedVersion = !IsReleaseMode + ? AvailableVersions?.FirstOrDefault(x => x.TagName == SelectedPackage.MainBranch) + ?? AvailableVersions?.FirstOrDefault() + : AvailableVersions?.FirstOrDefault(); + } + + private async Task UpdateCommits(string branchName) + { + var commits = await SelectedPackage.GetAllCommits(branchName); + if (commits != null) + { + AvailableCommits = new ObservableCollection(commits); + SelectedCommit = AvailableCommits.FirstOrDefault(); + } + } + + partial void OnInstallNameChanged(string? value) + { + ShowDuplicateWarning = settingsManager.Settings.InstalledPackages.Any( + p => p.LibraryPath == $"Packages{Path.DirectorySeparatorChar}{value}" + ); + } + + partial void OnIsReleaseModeChanged(bool value) + { + UpdateVersions(); + } + + partial void OnSelectedVersionChanged(PackageVersion? value) + { + if (IsReleaseMode) + return; + + UpdateCommits(value?.TagName ?? SelectedPackage.MainBranch).SafeFireAndForget(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs index 3e834be6..3fc0b95e 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs @@ -9,10 +9,12 @@ using Avalonia.Controls; using Avalonia.Controls.Notifications; using Avalonia.Controls.Primitives; using Avalonia.Threading; +using CommunityToolkit.Mvvm.Input; using DynamicData; using DynamicData.Binding; using FluentAvalonia.UI.Controls; using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; @@ -37,17 +39,17 @@ namespace StabilityMatrix.Avalonia.ViewModels; /// [View(typeof(PackageManagerPage))] -[Singleton] +[Singleton, ManagedService] public partial class PackageManagerViewModel : PageViewModelBase { private readonly ISettingsManager settingsManager; private readonly ServiceManager dialogFactory; private readonly INotificationService notificationService; + private readonly INavigationService packageNavigationService; private readonly ILogger logger; public override string Title => "Packages"; - public override IconSource IconSource => - new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true }; + public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true }; /// /// List of installed packages @@ -71,12 +73,14 @@ public partial class PackageManagerViewModel : PageViewModelBase ISettingsManager settingsManager, ServiceManager dialogFactory, INotificationService notificationService, + INavigationService packageNavigationService, ILogger logger ) { this.settingsManager = settingsManager; this.dialogFactory = dialogFactory; this.notificationService = notificationService; + this.packageNavigationService = packageNavigationService; this.logger = logger; EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged; @@ -118,10 +122,7 @@ public partial class PackageManagerViewModel : PageViewModelBase if (Design.IsDesignMode) return; - installedPackages.EditDiff( - settingsManager.Settings.InstalledPackages, - InstalledPackage.Comparer - ); + installedPackages.EditDiff(settingsManager.Settings.InstalledPackages, InstalledPackage.Comparer); var currentUnknown = await Task.Run(IndexUnknownPackages); unknownInstalledPackages.Edit(s => s.Load(currentUnknown)); @@ -135,43 +136,9 @@ public partial class PackageManagerViewModel : PageViewModelBase base.OnUnloaded(); } - public async Task ShowInstallDialog(BasePackage? selectedPackage = null) + public void ShowInstallDialog(BasePackage? selectedPackage = null) { - var viewModel = dialogFactory.Get(); - viewModel.SelectedPackage = selectedPackage ?? viewModel.AvailablePackages[0]; - - var dialog = new BetterContentDialog - { - MaxDialogWidth = 900, - MinDialogWidth = 900, - FullSizeDesired = true, - DefaultButton = ContentDialogButton.Close, - IsPrimaryButtonEnabled = false, - IsSecondaryButtonEnabled = false, - IsFooterVisible = false, - ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, - Content = new InstallerDialog { DataContext = viewModel } - }; - - var result = await dialog.ShowAsync(); - if (result == ContentDialogResult.Primary) - { - var runner = new PackageModificationRunner { ShowDialogOnStart = true }; - var steps = viewModel.Steps; - - EventManager.Instance.OnPackageInstallProgressAdded(runner); - await runner.ExecuteSteps(steps.ToList()); - - if (!runner.Failed) - { - EventManager.Instance.OnInstalledPackagesChanged(); - notificationService.Show( - "Package Install Complete", - $"{viewModel.InstallName} installed successfully", - NotificationType.Success - ); - } - } + NavigateToSubPage(typeof(PackageInstallBrowserViewModel)); } private async Task CheckPackagesForUpdates() @@ -204,11 +171,7 @@ public partial class PackageManagerViewModel : PageViewModelBase var currentPackages = settingsManager.Settings.InstalledPackages.ToImmutableArray(); - foreach ( - var subDir in packageDir.Info - .EnumerateDirectories() - .Select(info => new DirectoryPath(info)) - ) + foreach (var subDir in packageDir.Info.EnumerateDirectories().Select(info => new DirectoryPath(info))) { var expectedLibraryPath = $"Packages{Path.DirectorySeparatorChar}{subDir.Name}"; @@ -227,6 +190,19 @@ public partial class PackageManagerViewModel : PageViewModelBase } } + [RelayCommand] + private void NavigateToSubPage(Type viewModelType) + { + Dispatcher.UIThread.Post( + () => + packageNavigationService.NavigateTo( + viewModelType, + BetterSlideNavigationTransition.PageSlideFromRight + ), + DispatcherPriority.Send + ); + } + private void OnInstalledPackagesChanged(object? sender, EventArgs e) => OnLoadedAsync().SafeFireAndForget(); } diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs index f88d17f7..6f3c3c4c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs @@ -169,7 +169,7 @@ public partial class InferenceSettingsViewModel : PageViewModelBase var files = await storage.OpenFilePickerAsync( new FilePickerOpenOptions { - FileTypeFilter = new List { new("CSV") { Patterns = ["*.csv"] } } + FileTypeFilter = new List { new("CSV") { Patterns = ["*.csv"] } } } ); diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index 2b79de91..cfc781db 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -24,7 +24,9 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DynamicData.Binding; +using ExifLibrary; using FluentAvalonia.UI.Controls; +using MetadataExtractor.Formats.Exif; using NLog; using SkiaSharp; using StabilityMatrix.Avalonia.Animations; @@ -46,6 +48,7 @@ using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Python; @@ -74,8 +77,11 @@ public partial class MainSettingsViewModel : PageViewModelBase public SharedState SharedState { get; } + public bool IsMacOS => Compat.IsMacOS; + public override string Title => "Settings"; - public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; // ReSharper disable once MemberCanBeMadeStatic.Global public string AppVersion => @@ -132,7 +138,8 @@ public partial class MainSettingsViewModel : PageViewModelBase [ObservableProperty] private MemoryInfo memoryInfo; - private readonly DispatcherTimer hardwareInfoUpdateTimer = new() { Interval = TimeSpan.FromSeconds(2.627) }; + private readonly DispatcherTimer hardwareInfoUpdateTimer = + new() { Interval = TimeSpan.FromSeconds(2.627) }; public Task CpuInfoAsync => HardwareHelper.GetCpuInfoAsync(); @@ -198,8 +205,16 @@ public partial class MainSettingsViewModel : PageViewModelBase true ); - settingsManager.RelayPropertyFor(this, vm => vm.SelectedAnimationScale, settings => settings.AnimationScale); - settingsManager.RelayPropertyFor(this, vm => vm.HolidayModeSetting, settings => settings.HolidayModeSetting); + settingsManager.RelayPropertyFor( + this, + vm => vm.SelectedAnimationScale, + settings => settings.AnimationScale + ); + settingsManager.RelayPropertyFor( + this, + vm => vm.HolidayModeSetting, + settings => settings.HolidayModeSetting + ); DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); @@ -239,6 +254,12 @@ public partial class MainSettingsViewModel : PageViewModelBase { MemoryInfo = newMemoryInfo; } + + // Stop timer if live memory info is not available + if (!HardwareHelper.IsLiveMemoryUsageInfoAvailable) + { + (sender as DispatcherTimer)?.Stop(); + } } partial void OnSelectedThemeChanged(string? value) @@ -277,16 +298,14 @@ public partial class MainSettingsViewModel : PageViewModelBase CloseButtonText = Resources.Action_RelaunchLater }; - Dispatcher - .UIThread - .InvokeAsync(async () => + Dispatcher.UIThread.InvokeAsync(async () => + { + if (await dialog.ShowAsync() == ContentDialogResult.Primary) { - if (await dialog.ShowAsync() == ContentDialogResult.Primary) - { - Process.Start(Compat.AppCurrentPath); - App.Shutdown(); - } - }); + Process.Start(Compat.AppCurrentPath); + App.Shutdown(); + } + }); } else { @@ -313,16 +332,14 @@ public partial class MainSettingsViewModel : PageViewModelBase [RelayCommand] private void NavigateToSubPage(Type viewModelType) { - Dispatcher - .UIThread - .Post( - () => - settingsNavigationService.NavigateTo( - viewModelType, - BetterSlideNavigationTransition.PageSlideFromRight - ), - DispatcherPriority.Send - ); + Dispatcher.UIThread.Post( + () => + settingsNavigationService.NavigateTo( + viewModelType, + BetterSlideNavigationTransition.PageSlideFromRight + ), + DispatcherPriority.Send + ); } #region Package Environment @@ -350,8 +367,7 @@ public partial class MainSettingsViewModel : PageViewModelBase { // Save settings var newEnvVars = viewModel - .EnvVars - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) + .EnvVars.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) .GroupBy(kvp => kvp.Key, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal); settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars); @@ -405,7 +421,10 @@ public partial class MainSettingsViewModel : PageViewModelBase await using var _ = new MinimumDelay(200, 300); - var shortcutDir = new DirectoryPath(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs"); + var shortcutDir = new DirectoryPath( + Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), + "Programs" + ); var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); var appPath = Compat.AppCurrentPath; @@ -744,6 +763,100 @@ public partial class MainSettingsViewModel : PageViewModelBase } #endregion + #region Debug Commands + + public CommandItem[] DebugCommands => + [new CommandItem(DebugFindLocalModelFromIndexCommand), new CommandItem(DebugExtractDmgCommand)]; + + [RelayCommand] + private async Task DebugFindLocalModelFromIndex() + { + var textFields = new TextBoxField[] + { + new() { Label = "Blake3 Hash" }, + new() { Label = "SharedFolderType" } + }; + + var dialog = DialogHelper.CreateTextEntryDialog("Find Local Model", "", textFields); + + if (await dialog.ShowAsync() == ContentDialogResult.Primary) + { + Func>> modelGetter; + + if (textFields.ElementAtOrDefault(0)?.Text is { } hash && !string.IsNullOrWhiteSpace(hash)) + { + modelGetter = () => modelIndexService.FindByHashAsync(hash); + } + else if (textFields.ElementAtOrDefault(1)?.Text is { } type && !string.IsNullOrWhiteSpace(type)) + { + modelGetter = () => modelIndexService.FindAsync(Enum.Parse(type)); + } + else + { + return; + } + + var timer = Stopwatch.StartNew(); + + var result = (await modelGetter()).ToImmutableArray(); + + timer.Stop(); + + if (result.Length != 0) + { + await DialogHelper + .CreateMarkdownDialog( + string.Join( + "\n\n", + result.Select( + (model, i) => + $"[{i + 1}] {model.RelativePath.ToRepr()} " + + $"({model.DisplayModelName}, {model.DisplayModelVersion})" + ) + ), + $"Found Models ({CodeTimer.FormatTime(timer.Elapsed)})" + ) + .ShowAsync(); + } + else + { + await DialogHelper + .CreateMarkdownDialog(":(", $"No models found ({CodeTimer.FormatTime(timer.Elapsed)})") + .ShowAsync(); + } + } + } + + [RelayCommand(CanExecute = nameof(IsMacOS))] + private async Task DebugExtractDmg() + { + if (!Compat.IsMacOS) + return; + + // Select File + var files = await App.StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions { Title = "Select .dmg file" } + ); + if (files.FirstOrDefault()?.TryGetLocalPath() is not { } dmgFile) + return; + + // Select output directory + var folders = await App.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions { Title = "Select output directory" } + ); + if (folders.FirstOrDefault()?.TryGetLocalPath() is not { } outputDir) + return; + + // Extract + notificationService.Show("Extracting...", dmgFile); + + await ArchiveHelper.ExtractDmg(dmgFile, outputDir); + + notificationService.Show("Extraction Complete", dmgFile); + } + + #endregion + #region Info Section public void OnVersionClick() diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml index a044be5d..021dc27f 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml @@ -61,15 +61,19 @@ - - + + + True + + diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs index 086780a3..e95b45f4 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; @@ -44,7 +43,8 @@ public partial class CheckpointsPage : UserControlBase if (DataContext is CheckpointsPageViewModel vm) { - subscription = vm.WhenPropertyChanged(m => m.ShowConnectedModelImages).Subscribe(_ => InvalidateRepeater()); + subscription = vm.WhenPropertyChanged(m => m.ShowConnectedModelImages) + .Subscribe(_ => InvalidateRepeater()); } } @@ -152,7 +152,8 @@ public partial class CheckpointsPage : UserControlBase var parentFolder = file.ParentFolder; var dragFilePath = new FilePath(dragFile.FilePath); - parentFolder.IsCurrentDragTarget = dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath; + parentFolder.IsCurrentDragTarget = + dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath; break; } } diff --git a/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs b/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs index e149bf5f..281bfbea 100644 --- a/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/CivitAiBrowserPage.axaml.cs @@ -3,7 +3,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Core.Attributes; using CivitAiBrowserViewModel = StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser.CivitAiBrowserViewModel; diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml index 39152677..d39924d2 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml @@ -10,6 +10,9 @@ xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs" + xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" + xmlns:gif="clr-namespace:Avalonia.Gif;assembly=Avalonia.Gif" + xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch" d:DataContext="{x:Static mocks:DesignData.ImageViewerViewModel}" @@ -46,24 +49,46 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" RowDefinitions="*,Auto"> + + + - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -21,6 +23,11 @@ MinWidth="700" RowDefinitions="Auto, *, Auto" ColumnDefinitions="*,Auto,*"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs new file mode 100644 index 00000000..5abbe50b --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallBrowserView.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia.Input; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels.PackageManager; +using StabilityMatrix.Core.Attributes; + +namespace StabilityMatrix.Avalonia.Views.PackageManager; + +[Singleton] +public partial class PackageInstallBrowserView : UserControlBase +{ + public PackageInstallBrowserView() + { + InitializeComponent(); + } + + private void InputElement_OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape && DataContext is PackageInstallBrowserViewModel vm) + { + vm.ClearSearchQuery(); + } + } +} diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml new file mode 100644 index 00000000..88597fc8 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/PackageManager/PackageInstallDetailView.axaml @@ -0,0 +1,178 @@ + + + + + + + + + + + + public string FileNameWithoutExtension => Path.GetFileNameWithoutExtension(AbsolutePath); - public ( - string? Parameters, - string? ParametersJson, - string? SMProject, - string? ComfyNodes - ) ReadMetadata() + public (string? Parameters, string? ParametersJson, string? SMProject, string? ComfyNodes) ReadMetadata() { - using var stream = new FileStream( - AbsolutePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read - ); + if (AbsolutePath.EndsWith("webp")) + { + var paramsJson = ImageMetadata.ReadTextChunkFromWebp( + AbsolutePath, + ExifDirectoryBase.TagImageDescription + ); + var smProj = ImageMetadata.ReadTextChunkFromWebp(AbsolutePath, ExifDirectoryBase.TagSoftware); + + return (null, paramsJson, smProj, null); + } + + using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new BinaryReader(stream); var parameters = ImageMetadata.ReadTextChunk(reader, "parameters"); @@ -79,8 +81,30 @@ public record LocalImageFile public static LocalImageFile FromPath(FilePath filePath) { // TODO: Support other types - const LocalImageFileType imageType = - LocalImageFileType.Inference | LocalImageFileType.TextToImage; + const LocalImageFileType imageType = LocalImageFileType.Inference | LocalImageFileType.TextToImage; + + if (filePath.Extension.Equals(".webp", StringComparison.OrdinalIgnoreCase)) + { + var paramsJson = ImageMetadata.ReadTextChunkFromWebp( + filePath, + ExifDirectoryBase.TagImageDescription + ); + var parameters = string.IsNullOrWhiteSpace(paramsJson) + ? null + : JsonSerializer.Deserialize(paramsJson); + + filePath.Info.Refresh(); + + return new LocalImageFile + { + AbsolutePath = filePath, + ImageType = imageType, + CreatedAt = filePath.Info.CreationTimeUtc, + LastModifiedAt = filePath.Info.LastWriteTimeUtc, + GenerationParameters = parameters, + ImageSize = new Size(parameters?.Width ?? 0, parameters?.Height ?? 0) + }; + } // Get metadata using var stream = filePath.Info.OpenRead(); @@ -116,5 +140,11 @@ public record LocalImageFile } public static readonly HashSet SupportedImageExtensions = - new() { ".png", ".jpg", ".jpeg", ".webp" }; + [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp" + ]; } diff --git a/StabilityMatrix.Core/Models/Database/LocalModelFile.cs b/StabilityMatrix.Core/Models/Database/LocalModelFile.cs index e5f92cef..01db400b 100644 --- a/StabilityMatrix.Core/Models/Database/LocalModelFile.cs +++ b/StabilityMatrix.Core/Models/Database/LocalModelFile.cs @@ -6,13 +6,13 @@ namespace StabilityMatrix.Core.Models.Database; /// /// Represents a locally indexed model file. /// -public class LocalModelFile +public record LocalModelFile { /// /// Relative path to the file from the root model directory. /// [BsonId] - public required string RelativePath { get; set; } + public required string RelativePath { get; init; } /// /// Type of the model file. @@ -29,6 +29,11 @@ public class LocalModelFile /// public string? PreviewImageRelativePath { get; set; } + /// + /// Optional preview image full path. Takes priority over . + /// + public string? PreviewImageFullPath { get; set; } + /// /// File name of the relative path. /// @@ -45,7 +50,33 @@ public class LocalModelFile /// Relative file path from the shared folder type model directory. ///
[BsonIgnore] - public string RelativePathFromSharedFolder => Path.GetRelativePath(SharedFolderType.GetStringValue(), RelativePath); + public string RelativePathFromSharedFolder => + Path.GetRelativePath(SharedFolderType.GetStringValue(), RelativePath); + + /// + /// Blake3 hash of the file. + /// + public string? HashBlake3 => ConnectedModelInfo?.Hashes.BLAKE3; + + [BsonIgnore] + public string FullPathGlobal => GetFullPath(GlobalConfig.LibraryDir.JoinDir("Models")); + + [BsonIgnore] + public string? PreviewImageFullPathGlobal => + PreviewImageFullPath ?? GetPreviewImageFullPath(GlobalConfig.LibraryDir.JoinDir("Models")); + + [BsonIgnore] + public Uri? PreviewImageUriGlobal => + PreviewImageFullPathGlobal == null ? null : new Uri(PreviewImageFullPathGlobal); + + [BsonIgnore] + public string DisplayModelName => ConnectedModelInfo?.ModelName ?? FileNameWithoutExtension; + + [BsonIgnore] + public string DisplayModelVersion => ConnectedModelInfo?.VersionName ?? string.Empty; + + [BsonIgnore] + public string DisplayModelFileName => FileName; public string GetFullPath(string rootModelDirectory) { @@ -54,16 +85,15 @@ public class LocalModelFile public string? GetPreviewImageFullPath(string rootModelDirectory) { - return PreviewImageRelativePath == null ? null : Path.Combine(rootModelDirectory, PreviewImageRelativePath); - } + if (PreviewImageFullPath != null) + return PreviewImageFullPath; - [BsonIgnore] - public string FullPathGlobal => GetFullPath(GlobalConfig.LibraryDir.JoinDir("Models")); - - [BsonIgnore] - public string? PreviewImageFullPathGlobal => GetPreviewImageFullPath(GlobalConfig.LibraryDir.JoinDir("Models")); + return PreviewImageRelativePath == null + ? null + : Path.Combine(rootModelDirectory, PreviewImageRelativePath); + } - protected bool Equals(LocalModelFile other) + /*protected bool Equals(LocalModelFile other) { return RelativePath == other.RelativePath; } @@ -84,7 +114,7 @@ public class LocalModelFile public override int GetHashCode() { return RelativePath.GetHashCode(); - } + }*/ public static readonly HashSet SupportedCheckpointExtensions = [ @@ -94,6 +124,6 @@ public class LocalModelFile ".pth", ".bin" ]; - public static readonly HashSet SupportedImageExtensions = [".png", ".jpg", ".jpeg", ".gif"]; + public static readonly HashSet SupportedImageExtensions = [".png", ".jpg", ".jpeg", ".webp"]; public static readonly HashSet SupportedMetadataExtensions = [".json"]; } diff --git a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs index bd94d5f8..088cf592 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs @@ -57,6 +57,11 @@ public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable public long GetSize() { @@ -81,9 +86,7 @@ public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable file.Length); var subDirs = Info.GetDirectories() .Where(dir => !dir.Attributes.HasFlag(FileAttributes.ReparsePoint)) - .Sum( - dir => dir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length) - ); + .Sum(dir => dir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length)); return files + subDirs; } diff --git a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs index 494e3aa5..0c637c97 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs @@ -71,6 +71,11 @@ public partial class FilePath : FileSystemPath, IPathObject public FilePath(params string[] paths) : base(paths) { } + public FilePath RelativeTo(DirectoryPath path) + { + return new FilePath(Path.GetRelativePath(path.FullPath, FullPath)); + } + public long GetSize() { Info.Refresh(); diff --git a/StabilityMatrix.Core/Models/GenerationParameters.cs b/StabilityMatrix.Core/Models/GenerationParameters.cs index c0b5aa9b..46f7459a 100644 --- a/StabilityMatrix.Core/Models/GenerationParameters.cs +++ b/StabilityMatrix.Core/Models/GenerationParameters.cs @@ -20,6 +20,15 @@ public partial record GenerationParameters public int Width { get; set; } public string? ModelHash { get; set; } public string? ModelName { get; set; } + public int FrameCount { get; set; } + public int MotionBucketId { get; set; } + public int VideoQuality { get; set; } + public bool Lossless { get; set; } + public int Fps { get; set; } + public double OutputFps { get; set; } + public double MinCfg { get; set; } + public double AugmentationLevel { get; set; } + public string? VideoOutputMethod { get; set; } public static bool TryParse( string? text, diff --git a/StabilityMatrix.Core/Models/IHandleNavigation.cs b/StabilityMatrix.Core/Models/IHandleNavigation.cs new file mode 100644 index 00000000..14011508 --- /dev/null +++ b/StabilityMatrix.Core/Models/IHandleNavigation.cs @@ -0,0 +1,6 @@ +namespace StabilityMatrix.Core.Models; + +public interface IHandleNavigation +{ + bool GoBack(); +} diff --git a/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs b/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs new file mode 100644 index 00000000..686c09ee --- /dev/null +++ b/StabilityMatrix.Core/Models/PackageModification/InstallExtensionStep.cs @@ -0,0 +1,15 @@ +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Core.Models.PackageModification; + +public class InstallExtensionStep(ExtensionBase extension, DirectoryPath extensionsDir) : IPackageStep +{ + public Task ExecuteAsync(IProgress? progress = null) + { + return extension.InstallExtensionAsync(extensionsDir, extension.MainBranch); + } + + public string ProgressTitle => $"Installing {extension.DisplayName}"; +} diff --git a/StabilityMatrix.Core/Models/PackageType.cs b/StabilityMatrix.Core/Models/PackageType.cs new file mode 100644 index 00000000..92a2a847 --- /dev/null +++ b/StabilityMatrix.Core/Models/PackageType.cs @@ -0,0 +1,7 @@ +namespace StabilityMatrix.Core.Models; + +public enum PackageType +{ + SdInference, + SdTraining +} diff --git a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs index 373c19f4..ebe7856f 100644 --- a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs +++ b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using NLog; using Octokit; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; using StabilityMatrix.Core.Models.Database; @@ -147,10 +148,24 @@ public abstract class BaseGitPackage : BasePackage await VenvRunner.DisposeAsync().ConfigureAwait(false); } + // Set additional required environment variables + var env = new Dictionary(); + if (SettingsManager.Settings.EnvironmentVariables is not null) + { + env.Update(SettingsManager.Settings.EnvironmentVariables); + } + + if (Compat.IsWindows) + { + var tkPath = Path.Combine(SettingsManager.LibraryDir, "Assets", "Python310", "tcl", "tcl8.6"); + env["TCL_LIBRARY"] = tkPath; + env["TK_LIBRARY"] = tkPath; + } + VenvRunner = new PyVenvRunner(venvPath) { WorkingDirectory = installedPackagePath, - EnvironmentVariables = SettingsManager.Settings.EnvironmentVariables, + EnvironmentVariables = env }; if (!VenvRunner.Exists() || forceRecreate) diff --git a/StabilityMatrix.Core/Models/Packages/BasePackage.cs b/StabilityMatrix.Core/Models/Packages/BasePackage.cs index 063693e7..68fd939a 100644 --- a/StabilityMatrix.Core/Models/Packages/BasePackage.cs +++ b/StabilityMatrix.Core/Models/Packages/BasePackage.cs @@ -3,6 +3,7 @@ using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Packages.Extensions; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Python; @@ -47,6 +48,8 @@ public abstract class BasePackage public abstract PackageDifficulty InstallerSortOrder { get; } + public virtual PackageType PackageType => PackageType.SdInference; + public abstract Task DownloadPackage( string installLocation, DownloadPackageVersionOptions versionOptions, @@ -85,11 +88,20 @@ public abstract class BasePackage public abstract SharedFolderMethod RecommendedSharedFolderMethod { get; } - public abstract Task SetupModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod); + public abstract Task SetupModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ); - public abstract Task UpdateModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod); + public abstract Task UpdateModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ); - public abstract Task RemoveModelFolderLinks(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod); + public abstract Task RemoveModelFolderLinks( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ); public abstract Task SetupOutputFolderLinks(DirectoryPath installDirectory); public abstract Task RemoveOutputFolderLinks(DirectoryPath installDirectory); @@ -117,6 +129,11 @@ public abstract class BasePackage return TorchVersion.DirectMl; } + if (Compat.IsMacOS && Compat.IsArm && AvailableTorchVersions.Contains(TorchVersion.Mps)) + { + return TorchVersion.Mps; + } + return TorchVersion.Cpu; } @@ -142,7 +159,11 @@ public abstract class BasePackage public abstract Dictionary>? SharedOutputFolders { get; } public abstract Task GetAllVersionOptions(); - public abstract Task?> GetAllCommits(string branch, int page = 1, int perPage = 10); + public abstract Task?> GetAllCommits( + string branch, + int page = 1, + int perPage = 10 + ); public abstract Task GetLatestVersion(bool includePrerelease = false); public abstract string MainBranch { get; } public event EventHandler? Exited; @@ -153,7 +174,9 @@ public abstract class BasePackage public void OnStartupComplete(string url) => StartupComplete?.Invoke(this, url); public virtual PackageVersionType AvailableVersionTypes => - ShouldIgnoreReleases ? PackageVersionType.Commit : PackageVersionType.GithubRelease | PackageVersionType.Commit; + ShouldIgnoreReleases + ? PackageVersionType.Commit + : PackageVersionType.GithubRelease | PackageVersionType.Commit; protected async Task InstallCudaTorch( PyVenvRunner venvRunner, @@ -194,6 +217,9 @@ public abstract class BasePackage { progress?.Report(new ProgressReport(-1f, "Installing PyTorch for CPU", isIndeterminate: true)); - return venvRunner.PipInstall(new PipInstallArgs().WithTorch("==2.0.1").WithTorchVision(), onConsoleOutput); + return venvRunner.PipInstall( + new PipInstallArgs().WithTorch("==2.0.1").WithTorchVision(), + onConsoleOutput + ); } } diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index 81a415c4..5ac92976 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -6,6 +6,7 @@ using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Packages.Extensions; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Python; @@ -114,10 +115,36 @@ public class ComfyUI( { Name = "Use CPU only", Type = LaunchOptionType.Bool, - InitialValue = !HardwareHelper.HasNvidiaGpu() && !HardwareHelper.HasAmdGpu(), + InitialValue = + !Compat.IsMacOS && !HardwareHelper.HasNvidiaGpu() && !HardwareHelper.HasAmdGpu(), Options = ["--cpu"] }, new LaunchOptionDefinition + { + Name = "Cross Attention Method", + Type = LaunchOptionType.Bool, + InitialValue = Compat.IsMacOS ? "--use-pytorch-cross-attention" : null, + Options = + [ + "--use-split-cross-attention", + "--use-quad-cross-attention", + "--use-pytorch-cross-attention" + ] + }, + new LaunchOptionDefinition + { + Name = "Force Floating Point Precision", + Type = LaunchOptionType.Bool, + InitialValue = Compat.IsMacOS ? "--force-fp16" : null, + Options = ["--force-fp32", "--force-fp16"] + }, + new LaunchOptionDefinition + { + Name = "VAE Precision", + Type = LaunchOptionType.Bool, + Options = ["--fp16-vae", "--fp32-vae", "--bf16-vae"] + }, + new LaunchOptionDefinition { Name = "Disable Xformers", Type = LaunchOptionType.Bool, @@ -142,7 +169,14 @@ public class ComfyUI( public override string MainBranch => "master"; public override IEnumerable AvailableTorchVersions => - new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.DirectMl, TorchVersion.Rocm, TorchVersion.Mps }; + new[] + { + TorchVersion.Cpu, + TorchVersion.Cuda, + TorchVersion.DirectMl, + TorchVersion.Rocm, + TorchVersion.Mps + }; public override async Task InstallPackage( string installLocation, @@ -161,7 +195,9 @@ public class ComfyUI( await venvRunner.PipInstall("--upgrade pip wheel", onConsoleOutput).ConfigureAwait(false); - progress?.Report(new ProgressReport(-1f, "Installing Package Requirements...", isIndeterminate: true)); + progress?.Report( + new ProgressReport(-1f, "Installing Package Requirements...", isIndeterminate: true) + ); var pipArgs = new PipInstallArgs(); @@ -181,7 +217,12 @@ public class ComfyUI( TorchVersion.Cpu => "cpu", TorchVersion.Cuda => "cu121", TorchVersion.Rocm => "rocm5.6", - _ => throw new ArgumentOutOfRangeException(nameof(torchVersion), torchVersion, null) + _ + => throw new ArgumentOutOfRangeException( + nameof(torchVersion), + torchVersion, + null + ) } ) }; @@ -239,16 +280,23 @@ public class ComfyUI( } } - public override Task SetupModelFolders(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod) => + public override Task SetupModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) => sharedFolderMethod switch { - SharedFolderMethod.Symlink => base.SetupModelFolders(installDirectory, SharedFolderMethod.Symlink), + SharedFolderMethod.Symlink + => base.SetupModelFolders(installDirectory, SharedFolderMethod.Symlink), SharedFolderMethod.Configuration => SetupModelFoldersConfig(installDirectory), SharedFolderMethod.None => Task.CompletedTask, _ => throw new ArgumentOutOfRangeException(nameof(sharedFolderMethod), sharedFolderMethod, null) }; - public override Task RemoveModelFolderLinks(DirectoryPath installDirectory, SharedFolderMethod sharedFolderMethod) + public override Task RemoveModelFolderLinks( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) { return sharedFolderMethod switch { @@ -286,7 +334,9 @@ public class ComfyUI( throw new Exception("Invalid extra_model_paths.yaml"); } // check if we have a child called "stability_matrix" - var stabilityMatrixNode = mappingNode.Children.FirstOrDefault(c => c.Key.ToString() == "stability_matrix"); + var stabilityMatrixNode = mappingNode.Children.FirstOrDefault( + c => c.Key.ToString() == "stability_matrix" + ); if (stabilityMatrixNode.Key != null) { @@ -337,7 +387,11 @@ public class ComfyUI( { "hypernetworks", Path.Combine(modelsDir, "Hypernetwork") }, { "controlnet", - string.Join('\n', Path.Combine(modelsDir, "ControlNet"), Path.Combine(modelsDir, "T2IAdapter")) + string.Join( + '\n', + Path.Combine(modelsDir, "ControlNet"), + Path.Combine(modelsDir, "T2IAdapter") + ) }, { "clip", Path.Combine(modelsDir, "CLIP") }, { "clip_vision", Path.Combine(modelsDir, "InvokeClipVision") }, @@ -401,7 +455,9 @@ public class ComfyUI( mappingNode.Children.Remove("stability_matrix"); - var serializer = new SerializerBuilder().WithNamingConvention(UnderscoredNamingConvention.Instance).Build(); + var serializer = new SerializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); var yamlData = serializer.Serialize(mappingNode); await extraPathsYamlPath.WriteAllTextAsync(yamlData).ConfigureAwait(false); diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionBase.cs b/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionBase.cs new file mode 100644 index 00000000..240d39a3 --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/Extensions/ExtensionBase.cs @@ -0,0 +1,24 @@ +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Core.Models.Packages.Extensions; + +public abstract class ExtensionBase +{ + public string ByAuthor => $"By {Author}"; + + public abstract string RepoName { get; } + public abstract string DisplayName { get; set; } + public abstract string Author { get; } + + public abstract string Blurb { get; } + public abstract IEnumerable CompatibleWith { get; } + public abstract string MainBranch { get; } + + public abstract Task InstallExtensionAsync( + DirectoryPath installDirectory, + string branch, + CancellationToken cancellationToken = default + ); + + public string GithubUrl => $"https://github.com/{Author}/{RepoName}"; +} diff --git a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs index fa3adb34..c38c0e1f 100644 --- a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs +++ b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs @@ -32,7 +32,7 @@ public class KohyaSs( public override Uri PreviewImageUri => new( - "https://camo.githubusercontent.com/2170d2204816f428eec57ff87218f06344e0b4d91966343a6c5f0a76df91ec75/68747470733a2f2f696d672e796f75747562652e636f6d2f76692f6b35696d713031757655592f302e6a7067" + "https://camo.githubusercontent.com/5154eea62c113d5c04393e51a0d0f76ef25a723aad29d256dcc85ead1961cd41/68747470733a2f2f696d672e796f75747562652e636f6d2f76692f6b35696d713031757655592f302e6a7067" ); public override string OutputFolderName => string.Empty; @@ -40,14 +40,16 @@ public class KohyaSs( public override TorchVersion GetRecommendedTorchVersion() => TorchVersion.Cuda; - public override string Disclaimer => "Nvidia GPU with at least 8GB VRAM is recommended. May be unstable on Linux."; + public override string Disclaimer => + "Nvidia GPU with at least 8GB VRAM is recommended. May be unstable on Linux."; public override PackageDifficulty InstallerSortOrder => PackageDifficulty.UltraNightmare; - + public override PackageType PackageType => PackageType.SdTraining; public override bool OfferInOneClickInstaller => false; public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.None; - public override IEnumerable AvailableTorchVersions => new[] { TorchVersion.Cuda }; - public override IEnumerable AvailableSharedFolderMethods => new[] { SharedFolderMethod.None }; + public override IEnumerable AvailableTorchVersions => [TorchVersion.Cuda]; + public override IEnumerable AvailableSharedFolderMethods => + new[] { SharedFolderMethod.None }; public override List LaunchOptions => [ @@ -189,7 +191,9 @@ public class KohyaSs( """ ); - var replacementAcceleratePath = Compat.IsWindows ? @".\venv\scripts\accelerate" : "./venv/bin/accelerate"; + var replacementAcceleratePath = Compat.IsWindows + ? @".\venv\scripts\accelerate" + : "./venv/bin/accelerate"; var replacer = scope.InvokeMethod( "StringReplacer", @@ -238,7 +242,6 @@ public class KohyaSs( var args = $"\"{Path.Combine(installedPackagePath, command)}\" {arguments}"; - VenvRunner.EnvironmentVariables = GetEnvVars(installedPackagePath); VenvRunner.RunDetached(args.TrimEnd(), HandleConsoleOutput, OnExit); } @@ -246,23 +249,4 @@ public class KohyaSs( public override Dictionary>? SharedOutputFolders { get; } public override string MainBranch => "master"; - - private Dictionary GetEnvVars(string installDirectory) - { - // Set additional required environment variables - var env = new Dictionary(); - if (SettingsManager.Settings.EnvironmentVariables is not null) - { - env.Update(SettingsManager.Settings.EnvironmentVariables); - } - - if (!Compat.IsWindows) - return env; - - var tkPath = Path.Combine(SettingsManager.LibraryDir, "Assets", "Python310", "tcl", "tcl8.6"); - env["TCL_LIBRARY"] = tkPath; - env["TK_LIBRARY"] = tkPath; - - return env; - } } diff --git a/StabilityMatrix.Core/Models/Packages/OneTrainer.cs b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs new file mode 100644 index 00000000..d113cb2d --- /dev/null +++ b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Core.Models.Packages; + +[Singleton(typeof(BasePackage))] +public class OneTrainer( + IGithubApiCache githubApi, + ISettingsManager settingsManager, + IDownloadService downloadService, + IPrerequisiteHelper prerequisiteHelper +) : BaseGitPackage(githubApi, settingsManager, downloadService, prerequisiteHelper) +{ + public override string Name => "OneTrainer"; + public override string DisplayName { get; set; } = "OneTrainer"; + public override string Author => "Nerogar"; + public override string Blurb => + "OneTrainer is a one-stop solution for all your stable diffusion training needs"; + public override string LicenseType => "AGPL-3.0"; + public override string LicenseUrl => "https://github.com/Nerogar/OneTrainer/blob/master/LICENSE.txt"; + public override string LaunchCommand => "scripts/train_ui.py"; + + public override Uri PreviewImageUri => + new("https://github.com/Nerogar/OneTrainer/blob/master/resources/icons/icon.png?raw=true"); + + public override string OutputFolderName => string.Empty; + public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.None; + public override IEnumerable AvailableTorchVersions => [TorchVersion.Cuda]; + public override bool IsCompatible => HardwareHelper.HasNvidiaGpu(); + public override PackageType PackageType => PackageType.SdTraining; + public override IEnumerable AvailableSharedFolderMethods => + new[] { SharedFolderMethod.None }; + public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Nightmare; + public override bool OfferInOneClickInstaller => false; + public override bool ShouldIgnoreReleases => true; + + public override async Task InstallPackage( + string installLocation, + TorchVersion torchVersion, + SharedFolderMethod selectedSharedFolderMethod, + DownloadPackageVersionOptions versionOptions, + IProgress? progress = null, + Action? onConsoleOutput = null + ) + { + progress?.Report(new ProgressReport(-1f, "Setting up venv", isIndeterminate: true)); + + await using var venvRunner = new PyVenvRunner(Path.Combine(installLocation, "venv")); + venvRunner.WorkingDirectory = installLocation; + await venvRunner.Setup(true, onConsoleOutput).ConfigureAwait(false); + + progress?.Report(new ProgressReport(-1f, "Installing requirements", isIndeterminate: true)); + + var pipArgs = new PipInstallArgs("-r", "requirements.txt"); + await venvRunner.PipInstall(pipArgs, onConsoleOutput).ConfigureAwait(false); + } + + public override async Task RunPackage( + string installedPackagePath, + string command, + string arguments, + Action? onConsoleOutput + ) + { + await SetupVenv(installedPackagePath).ConfigureAwait(false); + var args = $"\"{Path.Combine(installedPackagePath, command)}\" {arguments}"; + + VenvRunner?.RunDetached(args.TrimEnd(), HandleConsoleOutput, HandleExit); + return; + + void HandleExit(int i) + { + Debug.WriteLine($"Venv process exited with code {i}"); + OnExit(i); + } + + void HandleConsoleOutput(ProcessOutput s) + { + onConsoleOutput?.Invoke(s); + } + } + + public override List LaunchOptions => [LaunchOptionDefinition.Extras]; + public override Dictionary>? SharedFolders { get; } + public override Dictionary>? SharedOutputFolders { get; } + public override string MainBranch => "master"; +} diff --git a/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs b/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs index 7eeae2f2..c35b37b8 100644 --- a/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs +++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionDirectMl.cs @@ -31,7 +31,6 @@ public class StableDiffusionDirectMl( public override string LaunchCommand => "launch.py"; public override Uri PreviewImageUri => new("https://github.com/lshqqytiger/stable-diffusion-webui-directml/raw/master/screenshot.png"); - public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink; public override TorchVersion GetRecommendedTorchVersion() => @@ -39,11 +38,6 @@ public class StableDiffusionDirectMl( public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Recommended; - public override IEnumerable AvailableTorchVersions => - new[] { TorchVersion.Cpu, TorchVersion.DirectMl }; - - public override bool ShouldIgnoreReleases => true; - public override List LaunchOptions { get @@ -64,6 +58,11 @@ public class StableDiffusionDirectMl( } } + public override IEnumerable AvailableTorchVersions => + new[] { TorchVersion.Cpu, TorchVersion.DirectMl }; + + public override bool ShouldIgnoreReleases => true; + public override async Task InstallPackage( string installLocation, TorchVersion torchVersion, diff --git a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs index 1f646d06..8da91d96 100644 --- a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs +++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs @@ -28,7 +28,8 @@ public class StableDiffusionUx( public override string DisplayName { get; set; } = "Stable Diffusion Web UI-UX"; public override string Author => "anapnoe"; public override string LicenseType => "AGPL-3.0"; - public override string LicenseUrl => "https://github.com/anapnoe/stable-diffusion-webui-ux/blob/master/LICENSE.txt"; + public override string LicenseUrl => + "https://github.com/anapnoe/stable-diffusion-webui-ux/blob/master/LICENSE.txt"; public override string Blurb => "A pixel perfect design, mobile friendly, customizable interface that adds accessibility, " + "ease of use and extended functionallity to the stable diffusion web ui."; @@ -38,7 +39,7 @@ public class StableDiffusionUx( public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink; - public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Simple; + public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Advanced; public override Dictionary> SharedFolders => new() diff --git a/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs index b6711cd5..c8ea41eb 100644 --- a/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs +++ b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs @@ -11,6 +11,9 @@ public record UpdatePlatforms [JsonPropertyName("linux-x64")] public UpdateInfo? LinuxX64 { get; init; } + [JsonPropertyName("macos-arm64")] + public UpdateInfo? MacOsArm64 { get; init; } + public UpdateInfo? GetInfoForCurrentPlatform() { if (Compat.IsWindows) @@ -23,6 +26,11 @@ public record UpdatePlatforms return LinuxX64; } + if (Compat.IsMacOS && Compat.IsArm) + { + return MacOsArm64; + } + return null; } } diff --git a/StabilityMatrix.Core/Processes/ProcessArgs.cs b/StabilityMatrix.Core/Processes/ProcessArgs.cs index 8fd296b3..d31d0953 100644 --- a/StabilityMatrix.Core/Processes/ProcessArgs.cs +++ b/StabilityMatrix.Core/Processes/ProcessArgs.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using OneOf; @@ -9,6 +10,7 @@ namespace StabilityMatrix.Core.Processes; /// Implicitly converts between string and string[], /// with no parsing if the input and output types are the same. /// +[CollectionBuilder(typeof(ProcessArgsCollectionBuilder), "Create")] public partial class ProcessArgs : OneOfBase, IEnumerable { /// @@ -19,8 +21,7 @@ public partial class ProcessArgs : OneOfBase, IEnumerable - public bool Contains(string arg) => - Match(str => str.Contains(arg), arr => arr.Any(x => x.Contains(arg))); + public bool Contains(string arg) => Match(str => str.Contains(arg), arr => arr.Any(x => x.Contains(arg))); public ProcessArgs Concat(ProcessArgs other) => Match( @@ -53,10 +54,7 @@ public partial class ProcessArgs : OneOfBase, IEnumerable - Match( - str => ArgumentsRegex().Matches(str).Select(x => x.Value.Trim('"')).ToArray(), - arr => arr - ); + Match(str => ArgumentsRegex().Matches(str).Select(x => x.Value.Trim('"')).ToArray(), arr => arr); // Implicit conversions @@ -71,3 +69,8 @@ public partial class ProcessArgs : OneOfBase, IEnumerable values) => new(values.ToArray()); +} diff --git a/StabilityMatrix.Core/Processes/ProcessResult.cs b/StabilityMatrix.Core/Processes/ProcessResult.cs index 9fca6171..509f7ab1 100644 --- a/StabilityMatrix.Core/Processes/ProcessResult.cs +++ b/StabilityMatrix.Core/Processes/ProcessResult.cs @@ -22,3 +22,13 @@ public readonly record struct ProcessResult } } } + +public static class ProcessResultTaskExtensions +{ + public static async Task EnsureSuccessExitCode(this Task task) + { + var result = await task.ConfigureAwait(false); + result.EnsureSuccessExitCode(); + return result; + } +} diff --git a/StabilityMatrix.Core/Processes/ProcessRunner.cs b/StabilityMatrix.Core/Processes/ProcessRunner.cs index f29fc72c..868eb247 100644 --- a/StabilityMatrix.Core/Processes/ProcessRunner.cs +++ b/StabilityMatrix.Core/Processes/ProcessRunner.cs @@ -92,7 +92,7 @@ public static class ProcessRunner else if (Compat.IsMacOS) { using var process = new Process(); - process.StartInfo.FileName = "explorer"; + process.StartInfo.FileName = "open"; process.StartInfo.Arguments = $"-R {Quote(filePath)}"; process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); @@ -331,19 +331,10 @@ public static class ProcessRunner { // Quote arguments containing spaces var args = string.Join(" ", arguments.Where(s => !string.IsNullOrEmpty(s)).Select(Quote)); - return StartAnsiProcess( - fileName, - args, - workingDirectory, - outputDataReceived, - environmentVariables - ); + return StartAnsiProcess(fileName, args, workingDirectory, outputDataReceived, environmentVariables); } - public static async Task RunBashCommand( - string command, - string workingDirectory = "" - ) + public static async Task RunBashCommand(string command, string workingDirectory = "") { // Escape any single quotes in the command var escapedCommand = command.Replace("\"", "\\\""); @@ -381,10 +372,7 @@ public static class ProcessRunner }; } - public static Task RunBashCommand( - IEnumerable commands, - string workingDirectory = "" - ) + public static Task RunBashCommand(IEnumerable commands, string workingDirectory = "") { // Quote arguments containing spaces var args = string.Join(" ", commands.Select(Quote)); @@ -433,9 +421,7 @@ public static class ProcessRunner catch (SystemException) { } throw new ProcessException( - "Process " - + (processName == null ? "" : processName + " ") - + $"failed with exit-code {process.ExitCode}." + "Process " + (processName == null ? "" : processName + " ") + $"failed with exit-code {process.ExitCode}." ); } } diff --git a/StabilityMatrix.Core/Python/PyRunner.cs b/StabilityMatrix.Core/Python/PyRunner.cs index b053a281..6e384449 100644 --- a/StabilityMatrix.Core/Python/PyRunner.cs +++ b/StabilityMatrix.Core/Python/PyRunner.cs @@ -12,13 +12,7 @@ using StabilityMatrix.Core.Python.Interop; namespace StabilityMatrix.Core.Python; [SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Global")] -public record struct PyVersionInfo( - int Major, - int Minor, - int Micro, - string ReleaseLevel, - int Serial -); +public record struct PyVersionInfo(int Major, int Minor, int Micro, string ReleaseLevel, int Serial); [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] [Singleton(typeof(IPyRunner))] @@ -32,8 +26,7 @@ public class PyRunner : IPyRunner // This is same for all platforms public const string PythonDirName = "Python310"; - public static string PythonDir => - Path.Combine(GlobalConfig.LibraryDir, "Assets", PythonDirName); + public static string PythonDir => Path.Combine(GlobalConfig.LibraryDir, "Assets", PythonDirName); /// /// Path to the Python Linked library relative from the Python directory. @@ -61,8 +54,7 @@ public class PyRunner : IPyRunner public static string GetPipPath => Path.Combine(PythonDir, "get-pip.pyc"); - public static string VenvPath => - Path.Combine(PythonDir, "Scripts", "virtualenv" + Compat.ExeExtension); + public static string VenvPath => Path.Combine(PythonDir, "Scripts", "virtualenv" + Compat.ExeExtension); public static bool PipInstalled => File.Exists(PipExePath); public static bool VenvInstalled => File.Exists(VenvPath); @@ -107,8 +99,7 @@ public class PyRunner : IPyRunner await RunInThreadWithLock(() => { var sys = - Py.Import("sys") as PyModule - ?? throw new NullReferenceException("sys module not found"); + Py.Import("sys") as PyModule ?? throw new NullReferenceException("sys module not found"); sys.Set("stdout", StdOutStream); sys.Set("stderr", StdErrStream); }) @@ -141,7 +132,7 @@ public class PyRunner : IPyRunner throw new FileNotFoundException("pip not found", PipExePath); } var result = await ProcessRunner - .GetProcessResultAsync(PipExePath, $"install {package}") + .GetProcessResultAsync(PythonExePath, $"-m pip install {package}") .ConfigureAwait(false); result.EnsureSuccessExitCode(); } diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index ed4a5763..dffbb79c 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -43,7 +43,12 @@ public class DownloadService : IDownloadService await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false); - await using var file = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None); + await using var file = new FileStream( + downloadPath, + FileMode.Create, + FileAccess.Write, + FileShare.None + ); long contentLength = 0; @@ -81,7 +86,9 @@ public class DownloadService : IDownloadService } } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var stream = await response + .Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); var totalBytesRead = 0L; var buffer = new byte[BufferSize]; while (true) @@ -143,7 +150,12 @@ public class DownloadService : IDownloadService File.Create(downloadPath).Close(); } - await using var file = new FileStream(downloadPath, FileMode.Append, FileAccess.Write, FileShare.None); + await using var file = new FileStream( + downloadPath, + FileMode.Append, + FileAccess.Write, + FileShare.None + ); // Remaining content length long remainingContentLength = 0; @@ -156,7 +168,9 @@ public class DownloadService : IDownloadService noRedirectRequest.Headers.Range = new RangeHeaderValue(existingFileSize, null); HttpResponseMessage? response = null; - foreach (var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 4)) + foreach ( + var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 4) + ) { var noRedirectResponse = await noRedirectClient .SendAsync(noRedirectRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken) @@ -197,7 +211,9 @@ public class DownloadService : IDownloadService var isIndeterminate = remainingContentLength == 0; - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var stream = await response + .Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); var totalBytesRead = 0L; var buffer = new byte[BufferSize]; while (true) @@ -252,7 +268,9 @@ public class DownloadService : IDownloadService var contentLength = 0L; - foreach (var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 3)) + foreach ( + var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), retryCount: 3) + ) { var response = await client .GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) @@ -303,7 +321,10 @@ public class DownloadService : IDownloadService ObjectHash.GetStringSignature(civitApi.ApiToken), url ); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", civitApi.ApiToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + civitApi.ApiToken + ); } } } diff --git a/StabilityMatrix.Core/Services/IModelIndexService.cs b/StabilityMatrix.Core/Services/IModelIndexService.cs index 83c7940b..69013b5b 100644 --- a/StabilityMatrix.Core/Services/IModelIndexService.cs +++ b/StabilityMatrix.Core/Services/IModelIndexService.cs @@ -12,18 +12,28 @@ public interface IModelIndexService /// Task RefreshIndex(); + /// + /// Starts a background task to refresh the local model file index. + /// + void BackgroundRefreshIndex(); + /// /// Get all models of the specified type from the existing (in-memory) index. /// IEnumerable GetFromModelIndex(SharedFolderType types); /// - /// Get all models of the specified type from the existing index. + /// Find all models of the specified SharedFolderType. /// - Task> GetModelsOfType(SharedFolderType type); + Task> FindAsync(SharedFolderType type); /// - /// Starts a background task to refresh the local model file index. + /// Find all models with the specified Blake3 hash. /// - void BackgroundRefreshIndex(); + Task> FindByHashAsync(string hashBlake3); + + /// + /// Remove a model from the index. + /// + Task RemoveModelAsync(LocalModelFile model); } diff --git a/StabilityMatrix.Core/Services/MetadataImportService.cs b/StabilityMatrix.Core/Services/MetadataImportService.cs index 5a88fb83..ca1803b4 100644 --- a/StabilityMatrix.Core/Services/MetadataImportService.cs +++ b/StabilityMatrix.Core/Services/MetadataImportService.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Diagnostics; +using System.Text.Json; using Microsoft.Extensions.Logging; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs index 67a67669..bd0182b3 100644 --- a/StabilityMatrix.Core/Services/ModelIndexService.cs +++ b/StabilityMatrix.Core/Services/ModelIndexService.cs @@ -1,6 +1,9 @@ -using System.Diagnostics; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics; using System.Text; using AsyncAwaitBestPractices; +using AutoCtor; using Microsoft.Extensions.Logging; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Database; @@ -13,45 +16,44 @@ using StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Core.Services; [Singleton(typeof(IModelIndexService))] -public class ModelIndexService : IModelIndexService +[AutoConstruct] +public partial class ModelIndexService : IModelIndexService { private readonly ILogger logger; - private readonly ILiteDbContext liteDbContext; private readonly ISettingsManager settingsManager; + private readonly ILiteDbContext liteDbContext; public Dictionary> ModelIndex { get; private set; } = new(); - public ModelIndexService( - ILogger logger, - ILiteDbContext liteDbContext, - ISettingsManager settingsManager - ) + [AutoPostConstruct] + private void Initialize() { - this.logger = logger; - this.liteDbContext = liteDbContext; - this.settingsManager = settingsManager; + // Start background index when library dir is set + settingsManager.RegisterOnLibraryDirSet(_ => BackgroundRefreshIndex()); } - /// - /// Deletes all entries in the local model file index. - /// - private async Task ClearIndex() + public IEnumerable GetFromModelIndex(SharedFolderType types) { - await liteDbContext.LocalModelFiles.DeleteAllAsync().ConfigureAwait(false); + return ModelIndex.Where(kvp => (kvp.Key & types) != 0).SelectMany(kvp => kvp.Value); } - public IEnumerable GetFromModelIndex(SharedFolderType types) + /// + public async Task> FindAsync(SharedFolderType type) { - return ModelIndex.Where(kvp => (kvp.Key & types) != 0).SelectMany(kvp => kvp.Value); + await liteDbContext.LocalModelFiles.EnsureIndexAsync(m => m.SharedFolderType).ConfigureAwait(false); + + return await liteDbContext + .LocalModelFiles.FindAsync(m => m.SharedFolderType == type) + .ConfigureAwait(false); } /// - public async Task> GetModelsOfType(SharedFolderType type) + public async Task> FindByHashAsync(string hashBlake3) { + await liteDbContext.LocalModelFiles.EnsureIndexAsync(m => m.HashBlake3).ConfigureAwait(false); + return await liteDbContext - .LocalModelFiles.Query() - .Where(m => m.SharedFolderType == type) - .ToArrayAsync() + .LocalModelFiles.FindAsync(m => m.HashBlake3 == hashBlake3) .ConfigureAwait(false); } @@ -86,6 +88,7 @@ public class ModelIndexService : IModelIndexService var added = 0; var newIndex = new Dictionary>(); + foreach ( var file in modelsDir .Info.EnumerateFiles("*.*", SearchOption.AllDirectories) @@ -161,6 +164,7 @@ public class ModelIndexService : IModelIndexService // Add to index var list = newIndex.GetOrAdd(sharedFolderType); list.Add(localModel); + added++; } @@ -195,4 +199,24 @@ public class ModelIndexService : IModelIndexService logger.LogError(ex, "Error in background model indexing"); }); } + + /// + public async Task RemoveModelAsync(LocalModelFile model) + { + // Remove from database + if (await liteDbContext.LocalModelFiles.DeleteAsync(model.RelativePath).ConfigureAwait(false)) + { + // Remove from index + if (ModelIndex.TryGetValue(model.SharedFolderType, out var list)) + { + list.Remove(model); + } + + EventManager.Instance.OnModelIndexChanged(); + + return true; + } + + return false; + } } diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index dc180367..61e04aa7 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -675,7 +675,20 @@ public class SettingsManager : ISettingsManager SettingsSerializerContext.Default.Settings ); - File.WriteAllBytes(SettingsPath, jsonBytes); + if (jsonBytes.Length == 0) + { + Logger.Error("JsonSerializer returned empty bytes for some reason"); + return; + } + + using var fs = File.Open(SettingsPath, FileMode.Open); + if (fs.CanWrite) + { + fs.Write(jsonBytes, 0, jsonBytes.Length); + fs.Flush(); + fs.SetLength(jsonBytes.Length); + } + fs.Close(); } finally { diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 6697dbee..44f76ba6 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -17,6 +17,7 @@ + @@ -25,9 +26,13 @@ + + + + @@ -49,6 +54,7 @@ + diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 7a7a1289..eff50ce3 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -10,6 +10,7 @@ using StabilityMatrix.Core.Models.Configs; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Update; +using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; namespace StabilityMatrix.Core.Updater; @@ -88,10 +89,7 @@ public class UpdateHelper : IUpdateHelper ) ) { - logger.LogInformation( - "Handling authenticated update download: {Url}", - updateInfo.Url - ); + logger.LogInformation("Handling authenticated update download: {Url}", updateInfo.Url); var path = updateInfo.Url.PathAndQuery.StripStart(authedPathPrefix); path = HttpUtility.UrlDecode(path); @@ -102,12 +100,7 @@ public class UpdateHelper : IUpdateHelper // Download update await downloadService - .DownloadToFileAsync( - url, - downloadFile, - progress: progress, - httpClientName: "UpdateClient" - ) + .DownloadToFileAsync(url, downloadFile, progress: progress, httpClientName: "UpdateClient") .ConfigureAwait(false); // Unzip if needed @@ -119,11 +112,12 @@ public class UpdateHelper : IUpdateHelper } extractDir.Create(); - progress.Report( - new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract) - ); + progress.Report(new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract)); + await ArchiveHelper.Extract(downloadFile, extractDir).ConfigureAwait(false); + progress.Report(new ProgressReport(1, isIndeterminate: true, type: ProgressType.Extract)); + // Find binary and move it up to the root var binaryFile = extractDir .EnumerateFiles("*.*", SearchOption.AllDirectories) @@ -131,6 +125,24 @@ public class UpdateHelper : IUpdateHelper await binaryFile.MoveToAsync(ExecutablePath).ConfigureAwait(false); } + else if (downloadFile.Extension == ".dmg") + { + if (!Compat.IsMacOS) + throw new NotSupportedException(".dmg is only supported on macOS"); + + if (extractDir.Exists) + { + await extractDir.DeleteAsync(true).ConfigureAwait(false); + } + extractDir.Create(); + + // Extract dmg contents + await ArchiveHelper.ExtractDmg(downloadFile, extractDir).ConfigureAwait(false); + + // Find app and move it up to the root + var appFile = extractDir.EnumerateFiles("*.app").First(); + await appFile.MoveToAsync(ExecutablePath).ConfigureAwait(false); + } // Otherwise just rename else { @@ -184,9 +196,7 @@ public class UpdateHelper : IUpdateHelper var channel in Enum.GetValues(typeof(UpdateChannel)) .Cast() .Where( - c => - c > UpdateChannel.Unknown - && c <= settingsManager.Settings.PreferredUpdateChannel + c => c > UpdateChannel.Unknown && c <= settingsManager.Settings.PreferredUpdateChannel ) ) { @@ -295,11 +305,7 @@ public class UpdateHelper : IUpdateHelper private void NotifyUpdateAvailable(UpdateInfo update) { - logger.LogInformation( - "Update available {AppVer} -> {UpdateVer}", - Compat.AppVersion, - update.Version - ); + logger.LogInformation("Update available {AppVer} -> {UpdateVer}", Compat.AppVersion, update.Version); EventManager.Instance.OnUpdateAvailable(update); } } diff --git a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj index 87423475..a0acdc33 100644 --- a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj +++ b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/StabilityMatrix.UITests/Extensions/WindowExtensions.cs b/StabilityMatrix.UITests/Extensions/WindowExtensions.cs index 922ea147..f86ad7b9 100644 --- a/StabilityMatrix.UITests/Extensions/WindowExtensions.cs +++ b/StabilityMatrix.UITests/Extensions/WindowExtensions.cs @@ -19,16 +19,12 @@ public static class WindowExtensions } if (targetVisualRoot.Equals(topLevel)) { - throw new ArgumentException( - "Target is not part of the same visual tree as the top level" - ); + throw new ArgumentException("Target is not part of the same visual tree as the top level"); } var point = - target.TranslatePoint( - new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), - topLevel - ) ?? throw new NullReferenceException("Point is null"); + target.TranslatePoint(new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), topLevel) + ?? throw new NullReferenceException("Point is null"); topLevel.MouseMove(point); topLevel.MouseDown(point, MouseButton.Left); @@ -48,16 +44,12 @@ public static class WindowExtensions } if (!targetVisualRoot.Equals(topLevel)) { - throw new ArgumentException( - "Target is not part of the same visual tree as the top level" - ); + throw new ArgumentException("Target is not part of the same visual tree as the top level"); } var point = - target.TranslatePoint( - new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), - topLevel - ) ?? throw new NullReferenceException("Point is null"); + target.TranslatePoint(new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), topLevel) + ?? throw new NullReferenceException("Point is null"); topLevel.MouseMove(point); topLevel.MouseDown(point, MouseButton.Left); @@ -68,6 +60,6 @@ public static class WindowExtensions // Return mouse to outside of window topLevel.MouseMove(new Point(-50, -50)); - Dispatcher.UIThread.Invoke(() => Dispatcher.UIThread.RunJobs()); + await Task.Delay(300); } } diff --git a/StabilityMatrix.UITests/MainWindowTests.cs b/StabilityMatrix.UITests/MainWindowTests.cs index cc2c3f13..1c002cf5 100644 --- a/StabilityMatrix.UITests/MainWindowTests.cs +++ b/StabilityMatrix.UITests/MainWindowTests.cs @@ -1,16 +1,9 @@ using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Threading; using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; -using FluentAvalonia.UI.Windowing; using Microsoft.Extensions.DependencyInjection; -using StabilityMatrix.Avalonia; -using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.ViewModels; -using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views; -using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.UITests.Extensions; namespace StabilityMatrix.UITests; @@ -18,126 +11,19 @@ namespace StabilityMatrix.UITests; [UsesVerify] [Collection("TempDir")] [TestCaseOrderer("StabilityMatrix.UITests.PriorityOrderer", "StabilityMatrix.UITests")] -public class MainWindowTests +public class MainWindowTests : TestBase { - private static IServiceProvider Services => App.Services; - - private static (AppWindow, MainWindowViewModel)? currentMainWindow; - - private static VerifySettings Settings - { - get - { - var settings = new VerifySettings(); - settings.IgnoreMembers( - vm => vm.Pages, - vm => vm.FooterPages, - vm => vm.CurrentPage - ); - settings.IgnoreMember(vm => vm.CurrentVersionText); - settings.DisableDiff(); - return settings; - } - } - - private static (AppWindow, MainWindowViewModel) GetMainWindow() - { - if (currentMainWindow is not null) - { - return currentMainWindow.Value; - } - - var window = Services.GetRequiredService(); - var viewModel = Services.GetRequiredService(); - window.DataContext = viewModel; - - window.SetDefaultFonts(); - window.Width = 1400; - window.Height = 900; - - App.VisualRoot = window; - App.StorageProvider = window.StorageProvider; - App.Clipboard = window.Clipboard ?? throw new NullReferenceException("Clipboard is null"); - - currentMainWindow = (window, viewModel); - return currentMainWindow.Value; - } - - private static BetterContentDialog? GetWindowDialog(Visual window) - { - return window - .FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType(); - } - - private static IEnumerable EnumerateWindowDialogs(Visual window) - { - return window - .FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType() - ?.FindDescendantOfType() - ?.GetVisualDescendants() - .OfType() ?? Enumerable.Empty(); - } - - private async Task<(BetterContentDialog, T)> WaitForDialog(Visual window) - where T : Control - { - var dialogs = await WaitHelper.WaitForConditionAsync( - () => EnumerateWindowDialogs(window).ToList(), - list => list.Any(dialog => dialog.Content is T) - ); - - if (dialogs.Count == 0) - { - throw new InvalidOperationException("No dialogs found"); - } - - var contentDialog = dialogs.First(dialog => dialog.Content is T); - - return (contentDialog, contentDialog.Content as T)!; - } - [AvaloniaFact, TestPriority(1)] public async Task MainWindow_ShouldOpen() { var (window, _) = GetMainWindow(); window.Show(); - await Task.Delay(300); - Dispatcher.UIThread.RunJobs(); - - // Find the select data directory dialog - var selectDataDirectoryDialog = await WaitHelper.WaitForNotNullAsync( - () => GetWindowDialog(window) - ); - Assert.NotNull(selectDataDirectoryDialog); - - // Click continue button - var continueButton = selectDataDirectoryDialog - .GetVisualDescendants() - .OfType