diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71d082e62..bf6f950a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,13 +5,291 @@ 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.15.8
+## v2.16.0
+### Added
+#### New Feature: π§ͺ Image Lab - Conversational Image Generation for ComfyUI
+- We've added a brand new conversational interface for image generation! Image Lab lets you iterate on images naturally through chat, rather than just one-off prompts.
+ - Local-First Power: Native support for Flux Kontext, Qwen Image Edit, and the Apache 2.0-licensed Flux.2 Klein running entirely locally via your ComfyUI backend.
+ - Smart Setup: Stability Matrix automatically detects and helps you download the specific models and LoRAs needed for these local workflows.
+ - Interactive Tools: Drag-and-drop image inputs, use the built-in annotation tool to draw on images, and keep persistent conversation history.
+ - Cloud Option: Includes optional support for Nano Banana (Gemini 3 Pro / 2.5) and Nano Banana 2 (Gemini 3.1 Flash) for users who want to leverage external reasoning models.
+- Added Regional Prompting addon to Inference - paint detailed masks to apply different prompts, strengths, and settings to specific regions of your image
+ - Multi-layer mask editor with Photoshop-style interface for managing layers with independent masks, prompts, colors, and opacity
+ - Professional brush tools: freehand brush/eraser with pressure sensitivity, rectangle/ellipse shapes with fill/stroke modes, paint bucket flood fill
+ - Brush feathering/softness control for smooth, blended mask edges (0 = hard edge, 1 = soft/blurred)
+ - Per-layer prompt and strength controls, export/import masks as PNG, duplicate layers, image reference layers for tracing
+ - GPU-accelerated rendering with compact gzip-compressed metadata serialization
+- Added official Inference support for the **Z-Image** (Base + Turbo), **Anima**, and **Flux.2** model architectures β workflow-appropriate text encoders, latent shapes, schedulers, and model sampling (AuraFlow for Z-Image, `Flux2Scheduler` for Flux.2) are wired up automatically across Text-to-Image and Image-to-Image
+- Added an Inference **Workflow** selector to the Model card with profiles for Default/Checkpoint, Flux, Flux.2, Z-Image Base/Turbo, Anima, HiDream, and Custom
+ - **Auto** (default) detects the workflow from the model's CivitAI metadata, with filename fallbacks for models without metadata, and shows the resolved profile inline below the selector
+ - Sparkle button applies recommended sampler / scheduler / steps / CFG presets for the active workflow β e.g. `res_multistep` / `simple` / 8 steps / CFG 1 for Z-Image Turbo, `er_sde` / `simple` / 30 steps / CFG 4 for Anima, `euler` / 20 steps / CFG 5 for Flux.2
+ - Choosing a non-Auto profile reveals a manual Encoder Type selector for advanced overrides (e.g. running Z-Image Turbo with the `sd3` encoder)
+ - Opening the model browser from the Model card pre-filters to the workflow's compatible base models, without overwriting your saved picker filters
+- Added CivArchive model browser with details page, image viewer, version selector, trigger words, and in-app downloads with tracked progress
+- Added a checkpoint organizer for previewing and reorganizing local models using connected metadata-driven folder and filename patterns (requested in [#280](https://github.com/LykosAI/StabilityMatrix/issues/280), [#424](https://github.com/LykosAI/StabilityMatrix/issues/424))
+- Added a new Model Picker dialog for Inference with grid/list views, search, filtering, and NSFW overlay
+- Added browse buttons to all model dropdowns in Inference (Model, Refiner, VAE, Text Encoders, CLIP Vision)
+- Added an inline search box to model combo box dropdowns with fuzzy matching
+- Added a **Source** button in the Inference SamplerCard that one-click matches your generation Width/Height to the loaded source image β available in Image-to-Image whenever a source image is selected
+- Added popularity counts to booru-style tag completions in the prompt editor; descriptions now show entries like `12.3K Β· artist` so the more common tags are easier to spot at a glance
+- Added a settings gear button to the CivitAI browser's Base Models filter flyout that jumps straight to the base model filter configuration in Settings
+- Added `er_sde` and `res_multistep` to the Inference sampler list
+- Added `stable_diffusion`, `flux2`, and `lumina2` Encoder Type options for UNet workflows
+- Added a **Bitsandbytes NF4** launch option to Stable Diffusion WebUI Forge - Neo for low-bit (`--bnb`) inference
+- Added an **Activity center**: the sidebar download panel now has a **Notifications** tab alongside **In Progress**. Toasts are clickable β jumping to the downloaded folder, the originating page (e.g. Inference), or the activity panel β and persist into a session notification history (every notification is recorded, even ones suppressed by your settings) with read/unread indicators and a combined unread + active-download badge on the sidebar item
+- Added an **"Always Show Scrollbars"** toggle under **Settings β Appearance**. Defaults on β vertical scrollbars stay visible at their full thickness and reserve real layout space instead of fading to a thin overlay-style bar that only thickens on hover. Toggle off to restore Avalonia's classic auto-hide behavior. Single-line numeric inputs (e.g. SamplerCard Width/Height) keep their auto-hide regardless so spin-buttons aren't followed by a phantom bar
+- Added new shared model folder categories β **Style Models**, **Audio Encoders**, **Model Patches**, and **Background Removal** β for ComfyUI's `style_models`, `audio_encoders`, `model_patches`, and `background_removal` directories. Models in these folders are now indexed and symlinked alongside everything else (e.g. Flux Redux / B-Lora style models, audio encoders for video/audio workflows, BiRefNet background-removal models)
+- Added Intel GPU support for ComfyUI
+- Added "Run Python Command" option to the package card's 3-dots menu for running arbitrary Python code in the package's virtual environment
+- Added a recoverable error dialog for UI thread exceptions, with option to continue instead of exiting
+- Added enable/disable toggle for environment variables in Settings, allowing variables to be temporarily disabled without deleting them
+### Changed
+- Promoted the Encoder Type selector in the Inference Model card out of Advanced Options up to the main card body, so it's visible whenever a non-Auto workflow profile is active (and always when **Custom** is selected)
+- Tidied up the Inference SamplerCard dimensions section β Source/Presets actions are shown as labeled buttons below the dimension row
+- The Inference checkpoint dropdown no longer **resets its scroll position** every time the model list refreshes. The refresh now applies a single combined (local + remote) diff to the underlying source cache, rather than first resetting to local-only and then re-adding remote entries β which previously caused the open dropdown to scroll back to the top
+- Local model autocomplete in the prompt editor now uses substring matching instead of prefix-only β typing any part of a model's filename surfaces it, with names that start with your search still ranked first
+- Single-encoder UNet workflows (Anima, Flux.2, Z-Image) now use the matching CLIPLoader instead of assuming Flux-style dual encoders
+- The CivitAI model details page now collapses the preview-image area and shows a small "No preview images available" hint when a model has no images to display, letting the description card take the full vertical space instead of leaving a large empty region above it
+- Improved the Gemini API error message in Image Lab when the API returns 401/403 to point users at Google's API key restriction policy (which starts blocking unrestricted keys on June 19 2026)
+- Improved safetensor checkpoint classification to correctly detect UNet-only models for Wan Video, HiDream, Z-Image, Hunyuan3D, and diffusers-format Flux architectures, ensuring they are routed to the DiffusionModels folder
+- GGUF checkpoint downloads now go directly to the DiffusionModels folder instead of StableDiffusion
+- Updated AI-Toolkit to install torch 2.9.1 / torchvision 0.24.1 / torchaudio 2.9.1 from the cu128 index to match upstream (ostris/ai-toolkit), with a cu126 fallback for legacy NVIDIA GPUs; also pin numpy to 1.26.4 to avoid a numpy 2.x ABI break in scipy/diffusers that crashed training runs
+- Pinned kohya_ss torch to 2.7.0 / torchvision 0.22.0 (cu128) to match upstream's requirements_pytorch_windows.txt instead of resolving an untested latest, keeping the cu126 legacy-GPU fallback
+- Pinned reForge torch to 2.9.0 to match upstream (modules/launch_utils.py)
+- Updated ComfyUI installs to cu130 (cu126 for legacy NVIDIA GPUs) / rocm7.2 torch indexes depending on GPU
+- Upgraded the bundled Visual C++ redistributable from 2015β2019 (v16) to 2015β2022 (v17, build 14.40.33810+), required by modern native dependencies such as PyTorch and ONNX Runtime
+- Video files can now be opened directly from the Output browser
+- Videos will now appear with thumbnails in the Output browser
+- Configured portable Git to suppress detached HEAD advice messages
+### Fixed
+- Fixed Inference text encoder selections being cleared when navigating away from and back to the Inference tab β encoder slots now ignore the transient null the model dropdown reports while its list refreshes
+- Fixed UNet-only Inference model selection sometimes clearing during model-list refreshes β text encoder slots no longer disappear after generating, cancelling a generation, or reconnecting to ComfyUI
+- Fixed [#1585](https://github.com/LykosAI/StabilityMatrix/issues/1585) - FluxGym installs/updates pulling an incompatible `transformers` version β installs now pin `transformers==4.54.1` and exclude it from the default requirements pass
+- Fixed [#1641](https://github.com/LykosAI/StabilityMatrix/issues/1641) - Cogstudio failing to set up its `inference/gradio_composite_demo` directory when the parent path didn't already exist
+- Fixed [#1650](https://github.com/LykosAI/StabilityMatrix/issues/1650) - ComfyUI-Manager extension installs failing on Linux with `File not found: venv/uv-build-constraints.txt` by no longer leaking the relative build-constraints path into the running package's environment
+- Fixed [#1645](https://github.com/LykosAI/StabilityMatrix/issues/1645) - Strix Halo / Radeon 8060S and other `Display controller`-class integrated GPUs not appearing in the GPU list on Linux
+- Fixed [#1643](https://github.com/LykosAI/StabilityMatrix/issues/1643) - package install and launch failures when `sitecustomize.py` or its compiled bytecode was corrupted by external software (e.g. some antivirus suites); the file now self-heals when out of date and its startup actions can no longer abort interpreter startup
+- Fixed the CivitAI model browser requiring two clicks of Search to show results when all base-model filters were selected β a leftover post-response sanity check from the old single-select base-model UI was rejecting the response the first time, requiring a second search to surface the cached results
+- Fixed CivitAI model cards showing "No versions available" when clicked for some models (typically recently uploaded or updated) even though the model has downloadable versions on the website β the app now retries with a different lookup path when the initial response comes back missing version data
+- Fixed `$#1234` and `civitai.com/models/1234` URL searches returning zero results for some models that exist and are downloadable on the website β the app now retries via a per-model lookup when the batch search misses a requested ID
+- Fixed `$#1234` searches with non-LORA / non-Checkpoint targets returning no results when the **Model Type** dropdown wasn't set to **All** β ID searches intentionally bypass the type and base-model filters in the request, but the post-response check was still rejecting the returned model when its type didn't match the dropdown
+- Fixed clicking a CivitAI model card with an empty version list appearing to do nothing for ~1β2s while the recovery round-trip runs β the clicked card now shows a "Loading..." state during the recovery, and the recovered version data is cached on the card so subsequent clicks are instant
+- Fixed "Invalid download link" error when using the browser extension
+- Fixed downloaded checkpoint going to StableDiffusion folder when a saved download preference existed, even for GGUF files that should always go to DiffusionModels
+- Fixed potential crash when adding metadata to malformed or non-PNG image data in Inference
+- Fixed non-Latin-1 characters (e.g. Japanese, Chinese, Korean, emoji) in image generation parameters being stored in PNG tEXt chunks, violating the PNG specification and causing character corruption (mojibake) in standard-compliant parsers. Non-Latin-1 content now uses spec-compliant iTXt chunks with proper UTF-8 encoding ([#1535](https://github.com/LykosAI/StabilityMatrix/issues/1535))
+- Fixed batch notification firing when only one image is generated
+### Security
+- Updated the bundled 7-Zip binaries (Windows, Linux, macOS) to **26.01**, which includes the fix for the NTFS heap buffer overflow CVE-2026-48095 ([GitHub Security Lab GHSL-2026-140](https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/), CVSS 8.8) and brings years of accumulated upstream security fixes β the Windows binary in particular had been pinned at the 2018 18.01 release
+- Package updates now re-run the prerequisite setup step (as installs already do), so the bundled 7-Zip binary is refreshed on update instead of only on a fresh package install
+### Supporters
+#### π Visionaries
+An enormous thank you to our incredible Visionaries: **Waterclouds**, **bluepopsicle**, **Ibixat**, **Droolguy**, **snotty**, **LG**, **whudunit**, **MrMxyzptlk12836**, **Psilocyfer18731**, **KalAbaddon**, and **moon_milky2843**! This was a huge release, and every bit of it rests on your generosity. Whether you've been cheering us on for years or only just joined, having you in our corner is what makes all of this possible. We're so grateful for you. π
+#### π Pioneers
+And what a Pioneer crew this release! A heartfelt thank you to the regulars who keep showing up for us: **Szir777**, **[USA]TechDude**, **SinthCore**, **Jisuren**, **Tigon**, **jweg79**, **rwx14662**, **Hurbie53**, **ahnhj.al**, **drew.lukas**, **Tuskaruho**, **Cjloha**, **Alligator1907**, **Bitti**, **damianpointdexter**, **Ghislain G**, and **tmdcks**! Your steady support release after release is what keeps us at the keyboard. And the warmest of welcomes to our newest Pioneers: **CommissarGiygas16050**, **qob97515211**, **bastardofbethlehem**, and **Zombop** β we're thrilled you've joined us, and we can't wait to get to know you! (And to our anonymous Pioneer out there too β our thanks reaches you. π)
+
+## v2.16.0-pre.2
+### Security
+- Updated the bundled 7-Zip binaries (Windows, Linux, macOS) to **26.01**, which includes the fix for the NTFS heap buffer overflow CVE-2026-48095 ([GitHub Security Lab GHSL-2026-140](https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/), CVSS 8.8) and brings years of accumulated upstream security fixes β the Windows binary in particular had been pinned at the 2018 18.01 release
+- Package updates now re-run the prerequisite setup step (as installs already do), so the bundled 7-Zip binary is refreshed on update instead of only on a fresh package install
+### Added
+- Added a **Source** button in the Inference SamplerCard that one-click matches your generation Width/Height to the loaded source image β available in Image-to-Image whenever a source image is selected
+- Added popularity counts to booru-style tag completions in the prompt editor; descriptions now show entries like `12.3K Β· artist` so the more common tags are easier to spot at a glance
+- Added a settings gear button to the CivitAI browser's Base Models filter flyout that jumps straight to the base model filter configuration in Settings
+- Added a **Bitsandbytes NF4** launch option to Stable Diffusion WebUI Forge - Neo for low-bit (`--bnb`) inference
+- Added an **Activity center**: the sidebar download panel now has a **Notifications** tab alongside **In Progress**. Toasts are clickable β jumping to the downloaded folder, the originating page (e.g. Inference), or the activity panel β and persist into a session notification history (every notification is recorded, even ones suppressed by your settings) with read/unread indicators and a combined unread + active-download badge on the sidebar item
+- Added **Gemini 3.1 Flash (Nano Banana 2)** as a new cloud provider in Image Lab β Google's latest fast image model, sitting between Gemini 2.5 Flash and Gemini 3 Pro in the dropdown. Uses the newer `thinking_level` config when thinking is enabled, and falls back to the model's default behavior otherwise
+- Added **Flux.2 Klein** as a new local provider in Image Lab. Klein 4B is **Apache 2.0 licensed** (commercial use free, unlike Flux.1 Kontext's non-commercial license), runs in just 4 sampling steps via the distilled variant, and supports up to 4 reference images per edit. Users with the Klein 9B UNET + matching Qwen3 8B text encoder dropped into their model folders will see those picked up automatically by the model dropdown
+- Added **Steps and CFG sliders** to the Flux.2 Klein settings panel. The defaults snap automatically to the recommended values for the selected variant (4 / 1 for distilled, 20 / 5 for base β including community 9B fine-tunes and "base & distilled" merge listings), and you can override either at any time
+- Added an **"Always Show Scrollbars"** toggle under **Settings β Appearance**. Defaults on β vertical scrollbars stay visible at their full thickness and reserve real layout space instead of fading to a thin overlay-style bar that only thickens on hover. Toggle off to restore Avalonia's classic auto-hide behavior. Single-line numeric inputs (e.g. SamplerCard Width/Height) keep their auto-hide regardless so spin-buttons aren't followed by a phantom bar
+- Added new shared model folder categories β **Style Models**, **Audio Encoders**, **Model Patches**, and **Background Removal** β for ComfyUI's `style_models`, `audio_encoders`, `model_patches`, and `background_removal` directories. Models in these folders are now indexed and symlinked alongside everything else (e.g. Flux Redux / B-Lora style models, audio encoders for video/audio workflows, BiRefNet background-removal models)
+- Added a **Download progress indicator** to the Image Lab status banner. While a model-download batch is in flight, the banner shows "β¬οΈ Downloading models (N/Total)..." with the count bumping as each file completes, instead of continuing to display the "missing models" warning
+- Greatly expanded native **Windows ROCm (AMD GPU)** support ([#1629](https://github.com/LykosAI/StabilityMatrix/pull/1629)) β the GPU detection matrix now spans **Vega / GCN5** (Vega 56/64, Radeon VII) through the entire **RDNA1/2/3/3.5** lineup and into **RDNA4** (RX 9070 / R9700), using TheRock ROCm Technical Preview PyTorch builds. ROCm install and launch now run through a shared helper, so the same Windows-native path is available to **ComfyUI, SwarmUI** (its Comfy backend), **reForge, InvokeAI, and Wan2GP** - thanks to @NeuralFault!
+- Added optional **ROCm package commands** for ComfyUI on Windows β one-click install of **SageAttention**, **Flash Attention**, **bitsandbytes**, and the **ROCm SDK devel** module (for compiling extensions/modules against your installed ROCm) from the package's command menu - thanks to @NeuralFault!
+### Changed
+- Tidied up the Inference SamplerCard dimensions section β Source/Presets actions are shown as labeled buttons below the dimension row
+- Promoted the Encoder Type selector in the Inference Model card out of Advanced Options up to the main card body, so it's visible whenever a non-Auto workflow profile is active (and always when **Custom** is selected)
+- Local model autocomplete in the prompt editor now uses substring matching instead of prefix-only β typing any part of a model's filename surfaces it, with names that start with your search still ranked first
+- Updated the bundled Qwen Image Edit model used by Image Lab to the **2511** build (Alibaba's November 2025 refresh) for better edit consistency, reduced drift on multi-turn edits, and stronger character/geometry preservation. Existing 2509 downloads continue to work β only newly installed setups pull the new file
+- Improved the Gemini API error message in Image Lab when the API returns 401/403 to point users at Google's API key restriction policy (which starts blocking unrestricted keys on June 19 2026)
+- Updated AI-Toolkit to install torch 2.9.1 / torchvision 0.24.1 / torchaudio 2.9.1 from the cu128 index to match upstream (ostris/ai-toolkit), with a cu126 fallback for legacy NVIDIA GPUs; also pin numpy to 1.26.4 to avoid a numpy 2.x ABI break in scipy/diffusers that crashed training runs
+- Pinned kohya_ss torch to 2.7.0 / torchvision 0.22.0 (cu128) to match upstream's requirements_pytorch_windows.txt instead of resolving an untested latest, keeping the cu126 legacy-GPU fallback
+- Pinned reForge torch to 2.9.0 to match upstream (modules/launch_utils.py)
+- Upgraded the bundled Visual C++ redistributable from 2015β2019 (v16) to 2015β2022 (v17, build 14.40.33810+), required by modern native dependencies such as PyTorch and ONNX Runtime
+- The CivitAI model details page now collapses the preview-image area and shows a small "No preview images available" hint when a model has no images to display, letting the description card take the full vertical space instead of leaving a large empty region above it
+- Restructured the Image Lab provider settings panels (Flux Kontext, Qwen Image Edit, Flux.2 Klein) β every provider now shows the model dropdown on top and a bordered **LoRA card** below it spanning full width, with header + Add button + selected-LoRA list all inside one consistent card. Klein gets a second column with the Steps/CFG sliders to the right of its model dropdown. All three panels collapse to a single stacked column when the chat panel is narrow (< 720 px), driven by a pure-XAML `Classes.compact` width-binding via a new `WidthLessThanConverter`
+- The Inference checkpoint dropdown no longer **resets its scroll position** every time the model list refreshes. The refresh now applies a single combined (local + remote) diff to the underlying source cache, rather than first resetting to local-only and then re-adding remote entries β which previously caused the open dropdown to scroll back to the top
+- **PyTorch TunableOp** is now disabled by default on Windows ROCm. If you have an existing TunableOp cache and want to keep tuning GEMM kernels, add `PYTORCH_TUNABLEOP_ENABLED=1` under **Settings β Environment Variables** - thanks to @NeuralFault!
+### Fixed
+- Fixed Inference text encoder selections being cleared when navigating away from and back to the Inference tab β encoder slots now ignore the transient null the model dropdown reports while its list refreshes
+- Fixed [#1585](https://github.com/LykosAI/StabilityMatrix/issues/1585) - FluxGym installs/updates pulling an incompatible `transformers` version β installs now pin `transformers==4.54.1` and exclude it from the default requirements pass
+- Fixed [#1641](https://github.com/LykosAI/StabilityMatrix/issues/1641) - Cogstudio failing to set up its `inference/gradio_composite_demo` directory when the parent path didn't already exist
+- Fixed [#1650](https://github.com/LykosAI/StabilityMatrix/issues/1650) - ComfyUI-Manager extension installs failing on Linux with `File not found: venv/uv-build-constraints.txt` by no longer leaking the relative build-constraints path into the running package's environment
+- Fixed [#1645](https://github.com/LykosAI/StabilityMatrix/issues/1645) - Strix Halo / Radeon 8060S and other `Display controller`-class integrated GPUs not appearing in the GPU list on Linux
+- Fixed [#1643](https://github.com/LykosAI/StabilityMatrix/issues/1643) - package install and launch failures when `sitecustomize.py` or its compiled bytecode was corrupted by external software (e.g. some antivirus suites); the file now self-heals when out of date and its startup actions can no longer abort interpreter startup
+- Fixed the CivitAI model browser requiring two clicks of Search to show results when all base-model filters were selected β a leftover post-response sanity check from the old single-select base-model UI was rejecting the response the first time, requiring a second search to surface the cached results
+- Fixed CivitAI model cards showing "No versions available" when clicked for some models (typically recently uploaded or updated) even though the model has downloadable versions on the website β the app now retries with a different lookup path when the initial response comes back missing version data
+- Fixed `$#1234` and `civitai.com/models/1234` URL searches returning zero results for some models that exist and are downloadable on the website β the app now retries via a per-model lookup when the batch search misses a requested ID
+- Fixed `$#1234` searches with non-LORA / non-Checkpoint targets returning no results when the **Model Type** dropdown wasn't set to **All** β ID searches intentionally bypass the type and base-model filters in the request, but the post-response check was still rejecting the returned model when its type didn't match the dropdown
+- Fixed clicking a CivitAI model card with an empty version list appearing to do nothing for ~1β2s while the recovery round-trip runs β the clicked card now shows a "Loading..." state during the recovery, and the recovered version data is cached on the card so subsequent clicks are instant
+- Fixed "Invalid download link" error when using the browser extension
+- Fixed the Image Lab status banner **flickering** between "Click Connect" and the button+text variants every second while ComfyUI was starting up β the per-attempt retry loop was toggling `IsConnecting` on/off, which propagated through `CanUserConnect` to the button's visibility. The retry loop now holds `IsWaitingForConnection = true` for its entire duration so the banner stays parked on "π Connecting to ComfyUI..."
+- Fixed Image Lab thinking/reasoning content (Gemini 3 Pro / Gemini 3.1 Flash) where the **last few characters of long lines were clipped** under the vertical scrollbar. The HTML body now reserves a 12 px right gutter via CSS padding so text always wraps before reaching the scrollbar zone
+- Fixed ComfyUI workflow rejections in Image Lab being surfaced as just "400 Bad Request" with no detail β the provider now logs the full JSON `node_errors` payload returned by ComfyUI so the failing node and validation error are visible in the log and the user-facing error message
+- Fixed a phantom up/down arrow pair appearing next to the spin buttons on `NumberBox` / `NumericUpDown` controls (e.g. SamplerCard Width/Height) after the always-show scrollbar change went in β TextBox-derived controls have an internal vertical `ScrollBar` in their template that should stay hidden when content fits, and the global override was forcing it visible. A `TextBox ScrollBar:vertical` carve-out now keeps those on auto-hide regardless of the global setting
+- Fixed user-set environment variables not overriding package-configured ROCm variables on Windows β launch env vars now layer as **helper defaults β package config β user-set**, so anything you set under **Settings β Environment Variables** always wins - thanks to @NeuralFault!
+- Fixed `pip show` "package not found" results being raised as exceptions instead of treated as a missing-package state, which could block installing optional modules (e.g. SageAttention, ROCm SDK) into a package's venv - thanks to @NeuralFault!
+### Supporters
+#### π Visionaries
+An enormous thank you to our incredible Visionaries: **Waterclouds**, **bluepopsicle**, **Ibixat**, **Droolguy**, **snotty**, **LG**, **whudunit**, **MrMxyzptlk12836**, **Psilocyfer18731**, **KalAbaddon**, and **moon_milky2843**! Your steadfast generosity is the foundation every feature and fix in this release is built on. Whether you've been with us for ages or joined just last release, we're endlessly grateful you're in our corner. Thank you for believing in what we're building. π
+
+## v2.16.0-pre.1
### Added
+- Added CivArchive model browser with details page, image viewer, version selector, trigger words, and in-app downloads with tracked progress
- Added support for the civitai.red (mature-content) domain β NSFW CivitAI links now open and copy as civitai.red URLs, and pasting a civitai.red URL into the CivitAI model browser search works the same as a civitai.com URL
+- Added official Inference support for the **Z-Image** (Base + Turbo), **Anima**, and **Flux.2** model architectures β workflow-appropriate text encoders, latent shapes, schedulers, and model sampling (AuraFlow for Z-Image, `Flux2Scheduler` for Flux.2) are wired up automatically across Text-to-Image and Image-to-Image
+- Added an Inference **Workflow** selector to the Model card with profiles for Default/Checkpoint, Flux, Flux.2, Z-Image Base/Turbo, Anima, HiDream, and Custom
+ - **Auto** (default) detects the workflow from the model's CivitAI metadata, with filename fallbacks for models without metadata, and shows the resolved profile inline below the selector
+ - Sparkle button applies recommended sampler / scheduler / steps / CFG presets for the active workflow β e.g. `res_multistep` / `simple` / 8 steps / CFG 1 for Z-Image Turbo, `er_sde` / `simple` / 30 steps / CFG 4 for Anima, `euler` / 20 steps / CFG 5 for Flux.2
+ - Choosing a non-Auto profile reveals a manual Encoder Type selector for advanced overrides (e.g. running Z-Image Turbo with the `sd3` encoder)
+ - Opening the model browser from the Model card pre-filters to the workflow's compatible base models, without overwriting your saved picker filters
+- Added `er_sde` and `res_multistep` to the Inference sampler list
+- Added `stable_diffusion`, `flux2`, and `lumina2` Encoder Type options for UNet workflows
+- Added a checkpoint organizer for previewing and reorganizing local models using connected metadata-driven folder and filename patterns (requested in [#280](https://github.com/LykosAI/StabilityMatrix/issues/280), [#424](https://github.com/LykosAI/StabilityMatrix/issues/424))
### Changed
- The CivitAI base model type filter now uses CivitAI's official `/api/v1/enums` endpoint, with fallbacks to the previous technique and a built-in list, so the filter stays populated even if the CivitAI response format changes or the service is unreachable
+- Single-encoder UNet workflows (Anima, Flux.2, Z-Image) now use the matching CLIPLoader instead of assuming Flux-style dual encoders
### Fixed
+- Fixed CivitAI model browsing breaking during Discovery API outages β the browser now falls back to the direct CivitAI API when Discovery returns a server error, authentication failure, or times out
+- Fixed UNet-only Inference model selection sometimes clearing during model-list refreshes β text encoder slots no longer disappear after generating, cancelling a generation, or reconnecting to ComfyUI
+- Fixed SwarmUI user settings (theme, output format, server configuration, etc.) and any user-added backend entries being overwritten when the install flow ran over an existing install β `Settings.fds` and `Backends.fds` are now merged with their existing contents instead of being rewritten from a stale template
+- Fixed pip requirements handling for environment-marker dependencies - thanks to @NeuralFault!
- Fixed [#1608](https://github.com/LykosAI/StabilityMatrix/issues/1608) - Crash when cdn fetch fails due to error notification not being shown on UI Thread - thanks to @NeuralFault!
+- Fixed ComfyUI-Zluda inheriting `--enable-manager` from the base ComfyUI launch options, which blocked the bundled custom-node manager from initializing - thanks to @NeuralFault!
+### Supporters
+#### π Visionaries
+So much love to our Visionaries β **Waterclouds**, **bluepopsicle**, **Ibixat**, **Droolguy**, **snotty**, **LG**, and **whudunit** β thank you for your continued enthusiasm, kindness, and sheer staying-power. You've been with us through some big changes, and we're so lucky to have you in our corner. And the warmest welcome to our newest Visionaries **MrMxyzptlk12836**, **Psilocyfer18731**, **KalAbaddon**, **RustCupcake**, and **moon_milky2843** β we're so happy you're here, and we can't wait to get to know you. π
+
+## v2.16.0-dev.3
+### Added
+- Added enable/disable toggle for environment variables in Settings, allowing variables to be temporarily disabled without deleting them
+- Added single-instance window activation signaling so reopening the app restores and focuses the existing desktop window instead of launching a duplicate instance
+- Added notification system with localizable banner and markdown detail dialog UI
+- Added warning in data directory selector when a OneDrive folder is selected
+- Added support in the Checkpoints page to distinguish standard updates from Early Access-only updates - thanks to @x0x0b!
+- Added torch index for Strix/Gorgon Point Ryzen AI APUs on Windows - thanks to @NeuralFault!
+- Added retry button to failed downloads - thanks to @NeuralFault!
+- Added new Membership support in Account Settings with Patreon migration prompt
+### Changed
+- Improved safetensor checkpoint classification to correctly detect UNet-only models for Wan Video, HiDream, Z-Image, Hunyuan3D, and diffusers-format Flux architectures, ensuring they are routed to the DiffusionModels folder
+- GGUF checkpoint downloads now go directly to the DiffusionModels folder instead of StableDiffusion
+- Configured portable Git to suppress detached HEAD advice messages
+- Settings file saves are now atomic to prevent corruption from interrupted writes
+- Updated torch indexes for A1111, ComfyUI, InvokeAI, and Forge-based UIs to rocm7.2 / cu128 depending on GPU - thanks to @NeuralFault!
+- Replaced the "Become a Patron" footer button with "Support Us", linking to the new direct Lykos support page at lykos.ai/membership
+- Updated the prompt dialog shown when enabling features like Accelerated Model Discovery to use Lykos accounts instead of Patreon linking
+- Moved the Patreon connection in Account Settings to a new "Legacy Connections" section, only shown for users with an existing Patreon link
+- Localized previously hardcoded strings on the Account Settings page (menu items, descriptions, section headers) and added Japanese, Korean, German, and French translations
+### Fixed
+- Fixed the Package Manager "Add Package" teaching tip opening inopportunely while packages were still loading or after opening the add-package dialog
+- Fixed downloaded checkpoint going to StableDiffusion folder when a saved download preference existed, even for GGUF files that should always go to DiffusionModels
+- Fixed potential crash when adding metadata to malformed or non-PNG image data in Inference
+- Fixed non-Latin-1 characters (e.g. Japanese, Chinese, Korean, emoji) in image generation parameters being stored in PNG tEXt chunks, violating the PNG specification and causing character corruption (mojibake) in standard-compliant parsers. Non-Latin-1 content now uses spec-compliant iTXt chunks with proper UTF-8 encoding ([#1535](https://github.com/LykosAI/StabilityMatrix/issues/1535))
+- Fixed an issue where `Align Your Steps` scheduler and Unet Loader workflows ignored Regional Prompting (and other addon) conditioning modifiers.
+- Fixed bold text not rendering in markdown dialogs on Windows 11 due to Avalonia 11.3.x variable font regression with Segoe UI Variable Text
+- Fixed Japanese text appearing compressed/squished in markdown dialogs by ensuring the bundled NotoSansJP font is used for CTextBlock rendering
+- Fixed ContentDialog title and buttons not using the correct font for Japanese locale (NotoSansJP) when shown as overlay
+- Added missing `CBold` and `CItalic` inline styles to the markdown style sheet
+- Fixed downloads failing with "The request message was already sent" when the server doesn't return Content-Length on the first attempt, caused by reusing a consumed HttpRequestMessage in the retry loop
+- Fixed downloads from sources that redirect to CivitAI/HuggingFace (e.g. CivArchive) failing with Unauthorized by resolving the redirect target URL and applying auth headers for the correct domain
+- Fixed dropdown menu overlayed in Inference UI Model Cards not being scrollable on Linux - thanks to @NeuralFault!
+- Fixed model downloads failing on VPN connections - thanks to @NeuralFault!
+- Fixed [#1598](https://github.com/LykosAI/StabilityMatrix/issues/1598) - download progress bar showing 100% immediately for fresh downloads due to missing Content-Length fallback when Content-Range header is absent
+- Fixed [#1597](https://github.com/LykosAI/StabilityMatrix/issues/1597) - reForge launch failing due to setuptools version
+- Fixed [#1596](https://github.com/LykosAI/StabilityMatrix/issues/1596) - package installs and managed embedded Python startup being poisoned by inherited shell Python activation variables such as `PYTHONHOME`, `PYTHONPATH`, `VIRTUAL_ENV`, and Conda environment variables
+- Fixed [#1590](https://github.com/LykosAI/StabilityMatrix/issues/1590) - Startup crash when settings file is corrupted. Settings files are now self-healing with automatic recovery from null bytes, truncated JSON, and missing brackets
+- Potentially fixed [#1578](https://github.com/LykosAI/StabilityMatrix/issues/1578) - `SocketException: Address already in use` on Linux startup by cleaning stale interprocess socket files and reactivating the existing window
+- Fixed [#1397](https://github.com/LykosAI/StabilityMatrix/issues/1397), [#610](https://github.com/LykosAI/StabilityMatrix/issues/610) - duplicate pip package entries in results - thanks to @e-nord!
+### Supporters
+#### π Visionaries
+A heartfelt thank you to our incredible Visionaries: **Waterclouds**, **JungleDragon**, **bluepopsicle**, **Bob S**, and **whudunit** - every feature, fix, and late-night breakthrough in this release carries your fingerprints. A huge welcome to our newest Visionaries **Droolguy** and **snotty** (leveling up from the Pioneer ranks!), a warm welcome back to longtime Visionary **Ibixat**, and an equally huge welcome to **LG**, making their Stability Matrix debut straight at the Visionary tier! You're the reason we can keep building bold things - and an extra-special thank you to everyone now supporting us directly through our new platform. Your trust in this next chapter means the world!
+
+## v2.16.0-dev.2
+### Added
+- Added Regional Prompting addon to Inference - paint detailed masks to apply different prompts, strengths, and settings to specific regions of your image
+ - Multi-layer mask editor with Photoshop-style interface for managing layers with independent masks, prompts, colors, and opacity
+ - Professional brush tools: freehand brush/eraser with pressure sensitivity, rectangle/ellipse shapes with fill/stroke modes, paint bucket flood fill
+ - Brush feathering/softness control for smooth, blended mask edges (0 = hard edge, 1 = soft/blurred)
+ - Per-layer prompt and strength controls, export/import masks as PNG, duplicate layers, image reference layers for tracing
+ - GPU-accelerated rendering with compact gzip-compressed metadata serialization
+- Added new Model Picker dialog for Inference with grid/list views, search, filtering, and NSFW overlay
+- Added browse buttons to all model dropdowns in Inference (Model, Refiner, VAE, Text Encoders, CLIP Vision)
+- Added inline search box to model combo box dropdowns with fuzzy matching
+- Added NVIDIA driver version warning when launching ComfyUI with CUDA 13.0 (cu130) and driver versions below 580.x
+- Added legacy Python warning when launching InvokeAI installations using Python 3.10.11
+- Added Tiled VAE Decode to the Inference video workflows - thanks to @NeuralFault!
+- Added recoverable error dialog for UI thread exceptions, with option to continue instead of exiting
+### Changed
+- Disabled update checking for legacy InvokeAI installations using Python 3.10.11
+- Hide rating stars in the Civitai browser page if no rating is available
+- Updated uv to v0.9.30
+- Updated PortableGit to v2.52.0.windows.1
+- Updated Sage/Triton/Nunchaku installers to use GitHub API to fetch latest releases
+- Updated ComfyUI installations and updates to automatically install ComfyUI Manager
+- Updated gfx110X Windows ROCm nightly index - thanks to @NeuralFault!
+- Updated ComfyUI-Zluda install to more closely match the author's intended installation method - thanks to @NeuralFault!
+- Updated Forge Classic installs/updates to use the upstream install script for better version compatibility with torch/sage/triton/nunchaku
+### Fixed
+- Fixed parsing of escape sequences in Inference such as `\\`
+- Fixed batch notification firing when only one image is generated
+- Fixed [#1546](https://github.com/LykosAI/StabilityMatrix/issues/1546), [#1541](https://github.com/LykosAI/StabilityMatrix/issues/1541) - "No module named 'pkg_resources'" error when installing Automatic1111/Forge/reForge packages
+- Fixed [#1545](https://github.com/LykosAI/StabilityMatrix/issues/1545), [#1518](https://github.com/LykosAI/StabilityMatrix/issues/1518), [#1513](https://github.com/LykosAI/StabilityMatrix/issues/1513), [#1488](https://github.com/LykosAI/StabilityMatrix/issues/1488) - Forge Neo update breaking things
+- Fixed [#1529](https://github.com/LykosAI/StabilityMatrix/issues/1529) - "Selected commit is null" error when installing packages and rate limited by GitHub
+- Fixed [#1525](https://github.com/LykosAI/StabilityMatrix/issues/1525) - Crash after downloading a model
+- Fixed [#1523](https://github.com/LykosAI/StabilityMatrix/issues/1523), [#1499](https://github.com/LykosAI/StabilityMatrix/issues/1499), [#1494](https://github.com/LykosAI/StabilityMatrix/issues/1494) - Automatic1111 using old stable diffusion repo
+- Fixed [#1505](https://github.com/LykosAI/StabilityMatrix/issues/1505) - incorrect port argument for Wan2GP
+- Possibly fix [#1502](https://github.com/LykosAI/StabilityMatrix/issues/1502) - English fonts not displaying correctly on Linux in Chinese environments
+- Fixed [#1476](https://github.com/LykosAI/StabilityMatrix/issues/1476) - Incorrect shared output folder for Forge Classic/Neo
+- Fixed [#1466](https://github.com/LykosAI/StabilityMatrix/issues/1466) - crash after moving portable install
+- Fixed [#1445](https://github.com/LykosAI/StabilityMatrix/issues/1445) - Linux app updates not actually updating - thanks to @NeuralFault!
+### Supporters
+#### π Visionaries
+Huge shoutout to our amazing Visionaries: **Waterclouds**, **JungleDragon**, **bluepopsicle**, **Bob S**, and **whudunit**! Your continued support fuels every new feature and improvement in Stability Matrix. We couldn't do it without you - thank you for believing in what we're building!
+
+## v2.16.0-dev.1
+### Added
+#### New Feature: π§ͺ Image Lab - Conversational Image Generation for ComfyUI
+- We've added a brand new conversational interface for image generation! Image Lab lets you iterate on images naturally through chat, rather than just one-off prompts.
+ - Local-First Power: Native support for Flux Kontext and Qwen Image Edit running entirely locally via your ComfyUI backend.
+ - Smart Setup: Stability Matrix automatically detects and helps you download the specific models and LoRAs needed for these local workflows.
+ - Interactive Tools: Drag-and-drop image inputs, use the built-in annotation tool to draw on images, and keep persistent conversation history.
+ - Cloud Option: Includes optional support for Nano Banana (Gemini 3 Pro/2.5) for users who want to leverage external reasoning models.
+- Added new package - [Wan2GP](https://github.com/deepbeepmeep/Wan2GP)
+- Added [Stable Diffusion WebUI Forge - Neo](https://github.com/Haoming02/sd-webui-forge-classic/tree/neo) as a separate package for convenience
+- Added Intel GPU support for ComfyUI
+- Added "Run Python Command" option to the package card's 3-dots menu for running arbitrary Python code in the package's virtual environment
+- Added togglable `--uv` argument to the SD.Next launch options
+- Added Tiled VAE decoding as an Inference addon thanks to @NeuralFault!
+### Changed
+- Moved the original Stable Diffusion WebUI Forge to the "Legacy" packages tab due to inactivity
+- Updated to cu130 torch index for ComfyUI installs with Nvidia GPUs
+- Consolidated and fixed AMD GPU architecture detection
+- Updated SageAttention installer to latest v2.2.0-windows.post4 version
+- Video files can now be opened directly from the Output browser
+- Videos will now appear with thumbnails in the Output browser
+### Fixed
+- Fixed [#1450](https://github.com/LykosAI/StabilityMatrix/issues/1450) - Older SD.Next not launching due to forced `--uv` argument
+- Fixed duplicate custom node installations when installing workflows from the Workflow Browser - thanks again to @NeuralFault!
+### Supporters
+#### π Visionaries
+A massive thank you to our esteemed Visionaries: **Waterclouds**, **JungleDragon**, **bluepopsicle**, **Bob S**, and **whudunit**! Your generosity is the powerhouse behind Stability Matrix, enabling us to keep building and refining with confidence. We are truly grateful for your partnership!
+
+## v2.15.8
+### Added
+- Added support for the civitai.red (mature-content) domain β NSFW CivitAI links now open and copy as civitai.red URLs, and pasting a civitai.red URL into the CivitAI model browser search works the same as a civitai.com URL
+### Changed
+- The CivitAI base model type filter now uses CivitAI's official `/api/v1/enums` endpoint, with fallbacks to the previous technique and a built-in list, so the filter stays populated even if the CivitAI response format changes or the service is unreachable
+### Fixed
- Fixed CivitAI model browsing breaking during Discovery API outages β the browser now falls back to the direct CivitAI API when Discovery returns a server error, authentication failure, or times out
- Fixed SwarmUI user settings (theme, output format, server configuration, etc.) and any user-added backend entries being overwritten when the install flow ran over an existing install β `Settings.fds` and `Backends.fds` are now merged with their existing contents instead of being rewritten from a stale template
- Fixed pip requirements handling for environment-marker dependencies - thanks to @NeuralFault!
diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml
index 3bbd19816..5c1cbbf4f 100644
--- a/StabilityMatrix.Avalonia/App.axaml
+++ b/StabilityMatrix.Avalonia/App.axaml
@@ -56,6 +56,7 @@
+
@@ -89,6 +90,7 @@
+
diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs
index 60ffe0216..0b3d14f8f 100644
--- a/StabilityMatrix.Avalonia/App.axaml.cs
+++ b/StabilityMatrix.Avalonia/App.axaml.cs
@@ -51,6 +51,7 @@
using StabilityMatrix.Avalonia.ViewModels.Progress;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Api;
+using StabilityMatrix.Core.Api.Handlers;
using StabilityMatrix.Core.Api.LykosAuthApi;
using StabilityMatrix.Core.Api.PromptGenApi;
using StabilityMatrix.Core.Attributes;
@@ -65,6 +66,7 @@
using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.ImageGeneration;
using StabilityMatrix.Core.Updater;
using ApiOptions = StabilityMatrix.Core.Models.Configs.ApiOptions;
using Application = Avalonia.Application;
@@ -149,6 +151,11 @@ public override void OnFrameworkInitializationCompleted()
{
base.OnFrameworkInitializationCompleted();
+ if (!Debugger.IsAttached || Program.Args.DebugExceptionDialog)
+ {
+ Dispatcher.UIThread.UnhandledException += Dispatcher_UnhandledException;
+ }
+
if (Design.IsDesignMode)
{
DesignData.DesignData.Initialize();
@@ -389,6 +396,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services)
{
provider.GetRequiredService(),
provider.GetRequiredService(),
+ provider.GetRequiredService(),
provider.GetRequiredService(),
provider.GetRequiredService(),
provider.GetRequiredService(),
@@ -503,8 +511,21 @@ internal static IServiceCollection ConfigureServices(bool disableMessagePipeInte
{
services.AddSingleton();
services.AddSingleton(p => p.GetRequiredService());
+
+ // BananaVision has its own database to preserve conversations when main DB is cleared
+ services.AddSingleton();
+ services.AddSingleton(p => p.GetRequiredService());
}
+ // Image generation services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
services.AddTransient(_ =>
{
var client = new GitHubClient(new ProductHeaderValue("StabilityMatrix"));
@@ -728,7 +749,7 @@ internal static IServiceCollection ConfigureServices(bool disableMessagePipeInte
}
)
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false })
- .AddPolicyHandler(retryPolicy)
+ .AddPolicyHandler(retryPolicyLonger)
.AddHttpMessageHandler(serviceProvider => new TokenAuthHeaderHandler(
serviceProvider.GetRequiredService()
));
@@ -764,6 +785,19 @@ internal static IServiceCollection ConfigureServices(bool disableMessagePipeInte
})
.AddPolicyHandler(retryPolicy); // Assuming retryPolicy is suitable
+ services
+ .AddRefitClient(defaultRefitSettings)
+ .ConfigureHttpClient(c =>
+ {
+ c.BaseAddress = new Uri("https://generativelanguage.googleapis.com");
+ c.Timeout = TimeSpan.FromMinutes(5); // Higher timeout for image generation
+ })
+ .AddHttpMessageHandler()
+ .AddPolicyHandler(retryPolicyLonger);
+
+ // Register GeminiApiKeyHandler
+ services.AddTransient();
+
// Apizr clients
services.AddApizrManagerFor(options =>
{
@@ -1039,6 +1073,14 @@ private static void OnServiceProviderDisposing(ServiceProvider serviceProvider)
Logger.Trace("Disposing {Count} Disposables", disposables.Count);
}
+ private static void Dispatcher_UnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
+ {
+ if (Program.ShowExceptionDialog(e.Exception, true))
+ {
+ e.Handled = true;
+ }
+ }
+
private static void TaskScheduler_UnobservedTaskException(
object? sender,
UnobservedTaskExceptionEventArgs e
diff --git a/StabilityMatrix.Avalonia/Assets.cs b/StabilityMatrix.Avalonia/Assets.cs
index c44005101..62047b196 100644
--- a/StabilityMatrix.Avalonia/Assets.cs
+++ b/StabilityMatrix.Avalonia/Assets.cs
@@ -105,7 +105,7 @@ internal static class Assets
new RemoteResource
{
Url = new Uri("https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip"),
- HashSha256 = "608619f8619075629c9c69f361352a0da6ed7e62f83a0e19c63e0ea32eb7629d"
+ HashSha256 = "608619f8619075629c9c69f361352a0da6ed7e62f83a0e19c63e0ea32eb7629d",
}
),
(
@@ -115,7 +115,7 @@ internal static class Assets
Url = new Uri(
"https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-x86_64-unknown-linux-gnu-install_only.tar.gz"
),
- HashSha256 = "c5bcaac91bc80bfc29cf510669ecad12d506035ecb3ad85ef213416d54aecd79"
+ HashSha256 = "c5bcaac91bc80bfc29cf510669ecad12d506035ecb3ad85ef213416d54aecd79",
}
),
(
@@ -124,7 +124,48 @@ internal static class Assets
{
// Requires our distribution with signed dylib for gatekeeper
Url = new Uri("https://cdn.lykos.ai/cpython-3.10.11-macos-arm64.zip"),
- HashSha256 = "83c00486e0af9c460604a425e519d58e4b9604fbe7a4448efda0f648f86fb6e3"
+ HashSha256 = "83c00486e0af9c460604a425e519d58e4b9604fbe7a4448efda0f648f86fb6e3",
+ }
+ )
+ );
+
+ ///
+ /// FFmpeg LGPL builds for video thumbnail generation.
+ ///
+ [SupportedOSPlatform("windows")]
+ [SupportedOSPlatform("linux")]
+ [SupportedOSPlatform("macos")]
+ public static RemoteResource FfmpegDownloadUrl =>
+ Compat.Switch(
+ (
+ PlatformKind.Windows | PlatformKind.X64,
+ new RemoteResource
+ {
+ // BtbN LGPL build - ffmpeg-n7.1-latest-win64-lgpl-7.1
+ Url = new Uri(
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-win64-lgpl-7.1.zip"
+ ),
+ HashSha256 = "a77ecdc794d67401f3e4976f8856065f7762d74afd16f9c7b777ff0291a7bcaa",
+ }
+ ),
+ (
+ PlatformKind.Linux | PlatformKind.X64,
+ new RemoteResource
+ {
+ // BtbN LGPL build - linux
+ Url = new Uri(
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz"
+ ),
+ HashSha256 = "d7d691dfa3a6d0a75362c02274a80a1f9635bd67908561aae31ee538853ab8ce",
+ }
+ ),
+ (
+ PlatformKind.MacOS | PlatformKind.Arm,
+ new RemoteResource
+ {
+ // evermeet.cx build for macOS arm64
+ Url = new Uri("https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip"),
+ HashSha256 = "8d7917c1cebd7a29e68c0a0a6cc4ecc3fe05c7fffed958636c7018b319afdda4",
}
)
);
@@ -135,18 +176,18 @@ internal static class Assets
new RemoteResource
{
Url = new Uri("https://cdn.lykos.ai/tags/danbooru.csv"),
- HashSha256 = "b84a879f1d9c47bf4758d66542598faa565b1571122ae12e7b145da8e7a4c1c6"
+ HashSha256 = "b84a879f1d9c47bf4758d66542598faa565b1571122ae12e7b145da8e7a4c1c6",
},
new RemoteResource
{
Url = new Uri("https://cdn.lykos.ai/tags/e621.csv"),
- HashSha256 = "ef7ea148ad865ad936d0c1ee57f0f83de723b43056c70b07fd67dbdbb89cae35"
+ HashSha256 = "ef7ea148ad865ad936d0c1ee57f0f83de723b43056c70b07fd67dbdbb89cae35",
},
new RemoteResource
{
Url = new Uri("https://cdn.lykos.ai/tags/danbooru_e621_merged.csv"),
- HashSha256 = "ac405ebce8b0caae363a7ef91f89beb4b8f60a7e218deb5078833686da6d497d"
- }
+ HashSha256 = "ac405ebce8b0caae363a7ef91f89beb4b8f60a7e218deb5078833686da6d497d",
+ },
};
public static Uri DiscordServerUrl { get; } = new("https://discord.com/invite/TUrgfECxHz");
diff --git a/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json b/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json
index 41d7c00c9..84a76b4c8 100644
--- a/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json
+++ b/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json
@@ -320,4 +320,4 @@
]
}
}
-}
+}
\ No newline at end of file
diff --git a/StabilityMatrix.Avalonia/Assets/hf-packages.json b/StabilityMatrix.Avalonia/Assets/hf-packages.json
index c3dd9c135..457952327 100644
--- a/StabilityMatrix.Avalonia/Assets/hf-packages.json
+++ b/StabilityMatrix.Avalonia/Assets/hf-packages.json
@@ -1223,12 +1223,11 @@
{
"ModelCategory": "Vae",
"ModelName": "Flux.1 VAE",
- "RepositoryPath": "black-forest-labs/FLUX.1-schnell",
+ "RepositoryPath": "Comfy-Org/Lumina_Image_2.0_Repackaged",
"Files": [
- "ae.safetensors"
+ "split_files/vae/ae.safetensors"
],
- "LicenseType": "Apache 2.0",
- "LoginRequired": true
+ "LicenseType": "Flux.1 Dev NonCommercial"
},
{
"ModelCategory": "Vae",
diff --git a/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs b/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs
index c27d649d6..5246b857a 100644
Binary files a/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs and b/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs differ
diff --git a/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt b/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt
index 8650d994b..d3cd9b0d6 100644
--- a/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt
+++ b/StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt
@@ -1,15 +1,16 @@
- 7-Zip
- ~~~~~
+ 7-Zip for Linux and macOS
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
License for use and distribution
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- 7-Zip Copyright (C) 1999-2023 Igor Pavlov.
+ 7-Zip Copyright (C) 1999-2026 Igor Pavlov.
The licenses for 7zz and 7zzs files are:
- The "GNU LGPL" as main license for most of the code
- The "GNU LGPL" with "unRAR license restriction" for some code
- The "BSD 3-clause License" for some code
+ - The "BSD 2-clause License" for some code
Redistributions in binary form must reproduce related license information from this file.
@@ -18,8 +19,8 @@
organization. You don't need to register or pay for 7-Zip.
- GNU LGPL information
- --------------------
+GNU LGPL information
+--------------------
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
@@ -37,52 +38,107 @@
- BSD 3-clause License
- --------------------
+BSD 3-clause License in 7-Zip code
+----------------------------------
- The "BSD 3-clause License" is used for the code in 7z.dll that implements LZFSE data decompression.
- That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
- that also uses the "BSD 3-clause License":
+ The "BSD 3-clause License" is used for the following code in 7z.dll
+ 1) LZFSE data decompression.
+ That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
+ that also uses the "BSD 3-clause License".
+ 2) ZSTD data decompression.
+ that code was developed using original zstd decoder code as reference code.
+ The original zstd decoder code was developed by Facebook Inc,
+ that also uses the "BSD 3-clause License".
- ----
- Copyright (c) 2015-2016, Apple Inc. All rights reserved.
+ Copyright (c) 2015-2016, Apple Inc. All rights reserved.
+ Copyright (c) Facebook, Inc. All rights reserved.
+ Copyright (c) 2023-2026 Igor Pavlov.
- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+Text of the "BSD 3-clause License"
+----------------------------------
- 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
- 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
- in the documentation and/or other materials provided with the distribution.
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
- 3. Neither the name of the copyright holder(s) nor the names of any contributors may be used to endorse or promote products derived
- from this software without specific prior written permission.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
- COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
- (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- ----
+3. Neither the name of the copyright holder nor the names of its contributors may
+ be used to endorse or promote products derived from this software without
+ specific prior written permission.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+---
- unRAR license restriction
- -------------------------
- The decompression engine for RAR archives was developed using source
- code of unRAR program.
- All copyrights to original unRAR code are owned by Alexander Roshal.
- The license for original unRAR code has the following restriction:
+BSD 2-clause License in 7-Zip code
+----------------------------------
- The unRAR sources cannot be used to re-create the RAR compression algorithm,
- which is proprietary. Distribution of modified unRAR sources in separate form
- or as a part of other software is permitted, provided that it is clearly
- stated in the documentation and source comments that the code may
- not be used to develop a RAR (WinRAR) compatible archiver.
+ The "BSD 2-clause License" is used for the XXH64 code in 7-Zip.
+ XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet.
- --
- Igor Pavlov
+ Copyright (c) 2012-2021 Yann Collet.
+ Copyright (c) 2023-2026 Igor Pavlov.
+
+Text of the "BSD 2-clause License"
+----------------------------------
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+---
+
+
+
+
+unRAR license restriction
+-------------------------
+
+The decompression engine for RAR archives was developed using source
+code of unRAR program.
+All copyrights to original unRAR code are owned by Alexander Roshal.
+
+The license for original unRAR code has the following restriction:
+
+ The unRAR sources cannot be used to re-create the RAR compression algorithm,
+ which is proprietary. Distribution of modified unRAR sources in separate form
+ or as a part of other software is permitted, provided that it is clearly
+ stated in the documentation and source comments that the code may
+ not be used to develop a RAR (WinRAR) compatible archiver.
+
+--
diff --git a/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz b/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz
index a7ea6fde5..4d038c946 100755
Binary files a/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz and b/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz differ
diff --git a/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz - LICENSE.txt b/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz - LICENSE.txt
index 8650d994b..d3cd9b0d6 100644
--- a/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz - LICENSE.txt
+++ b/StabilityMatrix.Avalonia/Assets/macos-arm64/7zz - LICENSE.txt
@@ -1,15 +1,16 @@
- 7-Zip
- ~~~~~
+ 7-Zip for Linux and macOS
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
License for use and distribution
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- 7-Zip Copyright (C) 1999-2023 Igor Pavlov.
+ 7-Zip Copyright (C) 1999-2026 Igor Pavlov.
The licenses for 7zz and 7zzs files are:
- The "GNU LGPL" as main license for most of the code
- The "GNU LGPL" with "unRAR license restriction" for some code
- The "BSD 3-clause License" for some code
+ - The "BSD 2-clause License" for some code
Redistributions in binary form must reproduce related license information from this file.
@@ -18,8 +19,8 @@
organization. You don't need to register or pay for 7-Zip.
- GNU LGPL information
- --------------------
+GNU LGPL information
+--------------------
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
@@ -37,52 +38,107 @@
- BSD 3-clause License
- --------------------
+BSD 3-clause License in 7-Zip code
+----------------------------------
- The "BSD 3-clause License" is used for the code in 7z.dll that implements LZFSE data decompression.
- That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
- that also uses the "BSD 3-clause License":
+ The "BSD 3-clause License" is used for the following code in 7z.dll
+ 1) LZFSE data decompression.
+ That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
+ that also uses the "BSD 3-clause License".
+ 2) ZSTD data decompression.
+ that code was developed using original zstd decoder code as reference code.
+ The original zstd decoder code was developed by Facebook Inc,
+ that also uses the "BSD 3-clause License".
- ----
- Copyright (c) 2015-2016, Apple Inc. All rights reserved.
+ Copyright (c) 2015-2016, Apple Inc. All rights reserved.
+ Copyright (c) Facebook, Inc. All rights reserved.
+ Copyright (c) 2023-2026 Igor Pavlov.
- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+Text of the "BSD 3-clause License"
+----------------------------------
- 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
- 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
- in the documentation and/or other materials provided with the distribution.
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
- 3. Neither the name of the copyright holder(s) nor the names of any contributors may be used to endorse or promote products derived
- from this software without specific prior written permission.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
- COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
- (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- ----
+3. Neither the name of the copyright holder nor the names of its contributors may
+ be used to endorse or promote products derived from this software without
+ specific prior written permission.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+---
- unRAR license restriction
- -------------------------
- The decompression engine for RAR archives was developed using source
- code of unRAR program.
- All copyrights to original unRAR code are owned by Alexander Roshal.
- The license for original unRAR code has the following restriction:
+BSD 2-clause License in 7-Zip code
+----------------------------------
- The unRAR sources cannot be used to re-create the RAR compression algorithm,
- which is proprietary. Distribution of modified unRAR sources in separate form
- or as a part of other software is permitted, provided that it is clearly
- stated in the documentation and source comments that the code may
- not be used to develop a RAR (WinRAR) compatible archiver.
+ The "BSD 2-clause License" is used for the XXH64 code in 7-Zip.
+ XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet.
- --
- Igor Pavlov
+ Copyright (c) 2012-2021 Yann Collet.
+ Copyright (c) 2023-2026 Igor Pavlov.
+
+Text of the "BSD 2-clause License"
+----------------------------------
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+---
+
+
+
+
+unRAR license restriction
+-------------------------
+
+The decompression engine for RAR archives was developed using source
+code of unRAR program.
+All copyrights to original unRAR code are owned by Alexander Roshal.
+
+The license for original unRAR code has the following restriction:
+
+ The unRAR sources cannot be used to re-create the RAR compression algorithm,
+ which is proprietary. Distribution of modified unRAR sources in separate form
+ or as a part of other software is permitted, provided that it is clearly
+ stated in the documentation and source comments that the code may
+ not be used to develop a RAR (WinRAR) compatible archiver.
+
+--
diff --git a/StabilityMatrix.Avalonia/Assets/sitecustomize.py b/StabilityMatrix.Avalonia/Assets/sitecustomize.py
index d154c13a0..7f9278f8d 100644
--- a/StabilityMatrix.Avalonia/Assets/sitecustomize.py
+++ b/StabilityMatrix.Avalonia/Assets/sitecustomize.py
@@ -46,12 +46,10 @@ def audit(event: str, *args):
# Reconfigure stdout to UTF-8
# noinspection PyUnresolvedReferences
-sys.stdin.reconfigure(encoding="utf-8")
-sys.stdout.reconfigure(encoding="utf-8")
-sys.stderr.reconfigure(encoding="utf-8")
-
-# Install the audit hook
-sys.addaudithook(audit)
+def _reconfigure_streams():
+ sys.stdin.reconfigure(encoding="utf-8")
+ sys.stdout.reconfigure(encoding="utf-8")
+ sys.stderr.reconfigure(encoding="utf-8")
# Patch Rich terminal detection
def _patch_rich_console():
@@ -81,9 +79,7 @@ def is_terminal(self) -> bool:
except ImportError:
pass
except Exception as e:
- print("[sitecustomize error]:", e)
-
-_patch_rich_console()
+ print("[sitecustomize error]:", e)
# Patch tqdm to use stdout instead of stderr
def _patch_tqdm():
@@ -97,4 +93,19 @@ def _patch_tqdm():
except Exception as e:
print("[sitecustomize error]:", e)
-_patch_tqdm()
+# Run startup customizations. Each is isolated so that a failure in one (or an
+# unusual host environment, e.g. an interpreter probe with no real stdio) can
+# never raise out of sitecustomize and abort interpreter startup.
+def _run_safely(func):
+ try:
+ func()
+ except Exception as e:
+ try:
+ print("[sitecustomize error]:", e)
+ except Exception:
+ pass
+
+_run_safely(_reconfigure_streams)
+_run_safely(lambda: sys.addaudithook(audit))
+_run_safely(_patch_rich_console)
+_run_safely(_patch_tqdm)
diff --git a/StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt b/StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt
index 80473a66f..dae57cb4f 100644
--- a/StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt
+++ b/StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt
@@ -1,43 +1,123 @@
-7-Zip Extra 18.01
------------------
+ 7-Zip Extra
+ ~~~~~~~~~~~
+ License for use and distribution
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-7-Zip Extra is package of extra modules of 7-Zip.
+ Copyright (C) 1999-2026 Igor Pavlov.
-7-Zip Copyright (C) 1999-2018 Igor Pavlov.
+ The licenses for files are:
-7-Zip is free software. Read License.txt for more information about license.
+ - 7za.exe:
+ - The "GNU LGPL" as main license for most of the code
+ - The "BSD 3-clause License" for some code
+ - The "BSD 2-clause License" for some code
+ - All other files: the "GNU LGPL".
-Source code of binaries can be found at:
- http://www.7-zip.org/
+ Redistributions in binary form must reproduce related license information from this file.
+ Note:
+ You can use 7-Zip Extra on any computer, including a computer in a commercial
+ organization. You don't need to register or pay for 7-Zip.
-7-Zip Extra
-~~~~~~~~~~~
-License for use and distribution
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ It is allowed to digitally sign DLL and EXE files included into this package
+ with arbitrary signatures of third parties.
-Copyright (C) 1999-2018 Igor Pavlov.
-7-Zip Extra files are under the GNU LGPL license.
+GNU LGPL information
+--------------------
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
-Notes:
- You can use 7-Zip Extra on any computer, including a computer in a commercial
- organization. You don't need to register or pay for 7-Zip.
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+ You can receive a copy of the GNU Lesser General Public License from
+ http://www.gnu.org/
-GNU LGPL information
---------------------
- This library is free software; you can redistribute it and/or
- modify it under the terms of the GNU Lesser General Public
- License as published by the Free Software Foundation; either
- version 2.1 of the License, or (at your option) any later version.
- This library is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- Lesser General Public License for more details.
+BSD 3-clause License in 7-Zip code
+----------------------------------
+
+ The "BSD 3-clause License" is used for the following code in 7za.exe
+ - ZSTD data decompression.
+ that code was developed using original zstd decoder code as reference code.
+ The original zstd decoder code was developed by Facebook Inc,
+ that also uses the "BSD 3-clause License".
+
+ Copyright (c) Facebook, Inc. All rights reserved.
+ Copyright (c) 2023-2025 Igor Pavlov.
+
+Text of the "BSD 3-clause License"
+----------------------------------
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may
+ be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+---
+
+
+
+
+BSD 2-clause License in 7-Zip code
+----------------------------------
+
+ The "BSD 2-clause License" is used for the XXH64 code in 7za.exe.
+
+ XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet.
+
+ Copyright (c) 2012-2021 Yann Collet.
+ Copyright (c) 2023-2025 Igor Pavlov.
+
+Text of the "BSD 2-clause License"
+----------------------------------
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- You can receive a copy of the GNU Lesser General Public License from
- http://www.gnu.org/
+---
diff --git a/StabilityMatrix.Avalonia/Assets/win-x64/7za.exe b/StabilityMatrix.Avalonia/Assets/win-x64/7za.exe
index a67de9158..25773795e 100644
Binary files a/StabilityMatrix.Avalonia/Assets/win-x64/7za.exe and b/StabilityMatrix.Avalonia/Assets/win-x64/7za.exe differ
diff --git a/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs b/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs
index 08126a7ab..a114dc50f 100644
--- a/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs
+++ b/StabilityMatrix.Avalonia/Controls/BetterComboBox.cs
@@ -1,76 +1,142 @@
-ο»Ώusing System.Reactive.Linq;
+using System.Reactive.Linq;
using System.Reactive.Subjects;
using Avalonia;
+using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Input;
-using Avalonia.Media;
using Avalonia.Threading;
+using FuzzySharp;
+using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Core.Extensions;
+using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Api.Comfy;
+using StabilityMatrix.Core.Models.Settings;
+using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.Controls;
public class BetterComboBox : ComboBox
{
+ private static readonly TimeSpan LegacySearchIdleResetDelay = TimeSpan.FromMilliseconds(1200);
+
+ public static readonly StyledProperty SearchWatermarkProperty = AvaloniaProperty.Register<
+ BetterComboBox,
+ string
+ >(nameof(SearchWatermark), defaultValue: "Search...");
+ public static readonly StyledProperty UseLegacySearchProperty = AvaloniaProperty.Register<
+ BetterComboBox,
+ bool
+ >(nameof(UseLegacySearch));
+ public static readonly DirectProperty SearchTextProperty =
+ AvaloniaProperty.RegisterDirect(nameof(SearchText), o => o.SearchText);
+
+ public string SearchWatermark
+ {
+ get => GetValue(SearchWatermarkProperty);
+ set => SetValue(SearchWatermarkProperty, value);
+ }
+
+ public bool UseLegacySearch
+ {
+ get => GetValue(UseLegacySearchProperty);
+ set => SetValue(UseLegacySearchProperty, value);
+ }
+
+ public string SearchText
+ {
+ get => searchText;
+ private set => SetAndRaise(SearchTextProperty, ref searchText, value);
+ }
+
private readonly Subject inputSubject = new();
private readonly IDisposable subscription;
- private readonly Popup inputPopup;
- private readonly TextBlock inputTextBlock;
- private string currentInput = string.Empty;
+ private readonly LRUCache searchCache = new(50);
+ private readonly ISettingsManager? settingsManager;
+ private readonly Popup legacyInputPopup;
+ private readonly TextBlock legacyInputTextBlock;
+ private readonly DispatcherTimer legacySearchResetTimer = new() { Interval = LegacySearchIdleResetDelay };
+ private TextBox? searchTextBox;
+ private string keyboardSearchText = string.Empty;
+ private string searchText = string.Empty;
+ private string lastAppliedFilter = string.Empty;
+ private bool isUpdatingSearchText;
public BetterComboBox()
{
- // Create an observable that buffers input over a short period
+ DropDownOpened += OnDropDownOpened;
+ DropDownClosed += OnDropDownClosed;
+ ContainerPrepared += OnContainerPrepared;
+ ContainerIndexChanged += OnContainerIndexChanged;
+
var inputObservable = inputSubject
- .Do(text => currentInput += text)
- .Throttle(TimeSpan.FromMilliseconds(500))
- .Where(_ => !string.IsNullOrEmpty(currentInput))
- .Select(_ => currentInput);
+ .Select(text => text.Trim())
+ .Throttle(TimeSpan.FromMilliseconds(200))
+ .DistinctUntilChanged();
- // Subscribe to the observable to filter the ComboBox items
subscription = inputObservable
.ObserveOn(SynchronizationContext.Current)
- .Subscribe(OnInputReceived, _ => ResetPopupText());
+ .Subscribe(OnInputReceived, _ => ResetSearchText());
+ legacySearchResetTimer.Tick += OnLegacySearchResetTimerTick;
- // Initialize the popup
- inputPopup = new Popup
+ legacyInputTextBlock = new TextBlock { FontSize = 13 };
+ legacyInputTextBlock.Bind(
+ TextBlock.ForegroundProperty,
+ this.GetResourceObservable("ComboBoxForeground")
+ );
+ var popupBorder = new Border { Padding = new Thickness(8, 4), Child = legacyInputTextBlock };
+ popupBorder.Bind(Border.BackgroundProperty, this.GetResourceObservable("ComboBoxDropDownBackground"));
+ legacyInputPopup = new Popup
{
IsLightDismissEnabled = true,
Placement = PlacementMode.AnchorAndGravity,
PlacementAnchor = PopupAnchor.Bottom,
PlacementGravity = PopupGravity.Top,
+ VerticalOffset = -6,
+ Child = popupBorder,
};
- // Initialize the TextBlock with custom styling
- inputTextBlock = new TextBlock
+ if (!Design.IsDesignMode)
{
- Foreground = Brushes.White, // White text color
- Background = Brush.Parse("#333333"), // Dark gray background
- Padding = new Thickness(8), // Add padding
- FontSize = 14 // Optional: adjust font size
- };
-
- inputPopup.Child = inputTextBlock;
+ settingsManager = App.Services.GetService();
+ if (settingsManager is not null)
+ {
+ ApplyGlobalLegacySearchOverride(settingsManager.Settings.UseLegacySearch);
+ settingsManager.SettingsPropertyChanged += OnSettingsPropertyChanged;
+ }
+ }
}
///
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
+ legacyInputPopup.PlacementTarget = this;
- // Set the Popup's anchor to the ComboBox itself
- inputPopup.PlacementTarget = this;
-
- if (e.NameScope.Find("ContentPresenter") is { } contentPresenter)
+ if (e.NameScope.Find("ContentPresenter") is { } contentPresenter)
{
if (SelectionBoxItemTemplate is { } template)
{
contentPresenter.ContentTemplate = template;
}
}
+
+ if (searchTextBox is not null)
+ {
+ searchTextBox.TextChanged -= SearchTextBoxOnTextChanged;
+ searchTextBox.KeyDown -= SearchTextBoxOnKeyDown;
+ }
+
+ searchTextBox = e.NameScope.Find("PART_SearchTextBox");
+ if (searchTextBox is not null)
+ {
+ AutomationProperties.SetName(searchTextBox, "Search models");
+ searchTextBox.TextChanged += SearchTextBoxOnTextChanged;
+ searchTextBox.KeyDown += SearchTextBoxOnKeyDown;
+ }
}
protected override void OnTextInput(TextInputEventArgs e)
@@ -78,72 +144,438 @@ protected override void OnTextInput(TextInputEventArgs e)
if (e.Handled)
return;
+ if (searchTextBox?.IsFocused == true)
+ {
+ base.OnTextInput(e);
+ return;
+ }
+
if (!string.IsNullOrWhiteSpace(e.Text))
{
- // Push the input text to the subject
- inputSubject.OnNext(e.Text);
- UpdatePopupText(e.Text);
+ keyboardSearchText += e.Text;
+ inputSubject.OnNext(keyboardSearchText);
+ RestartLegacySearchResetTimer();
+ UpdateLegacySearchPopupText(keyboardSearchText);
+
+ if (IsDropDownOpen)
+ {
+ UpdateSearchTextBoxText(keyboardSearchText);
+ if (!UseLegacySearch)
+ {
+ Dispatcher.UIThread.Post(() => searchTextBox?.Focus(), DispatcherPriority.Input);
+ }
+ }
+
e.Handled = true;
}
base.OnTextInput(e);
}
- private void OnInputReceived(string input)
+ private void SearchTextBoxOnTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ if (isUpdatingSearchText || sender is not TextBox textBox)
+ return;
+
+ keyboardSearchText = textBox.Text ?? string.Empty;
+ SearchText = keyboardSearchText;
+ inputSubject.OnNext(keyboardSearchText);
+ RestartLegacySearchResetTimer();
+ UpdateLegacySearchPopupText(keyboardSearchText);
+ }
+
+ private void SearchTextBoxOnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Escape)
+ return;
+
+ StopLegacySearchResetTimer();
+ IsDropDownOpen = false;
+ e.Handled = true;
+ }
+
+ private void OnDropDownOpened(object? sender, EventArgs e)
{
- if (Items.OfType().ToList() is { Count: > 0 } enumItems)
+ StopLegacySearchResetTimer();
+ ResetSearchText();
+ ApplyFilter(string.Empty);
+ if (!UseLegacySearch)
{
- var foundEnum = enumItems.FirstOrDefault(
- x => x.GetStringValue().StartsWith(input, StringComparison.OrdinalIgnoreCase)
- );
+ Dispatcher.UIThread.Post(() => searchTextBox?.Focus(), DispatcherPriority.Input);
+ }
+ }
+
+ private void OnDropDownClosed(object? sender, EventArgs e)
+ {
+ StopLegacySearchResetTimer();
+ ResetSearchText();
+ ApplyFilter(string.Empty);
+ }
+
+ private void UpdateSearchTextBoxText(string text)
+ {
+ SearchText = text;
+ UpdateLegacySearchPopupText(text);
+
+ if (searchTextBox is null)
+ return;
- if (foundEnum is not null)
+ isUpdatingSearchText = true;
+ searchTextBox.Text = text;
+ searchTextBox.CaretIndex = searchTextBox.Text?.Length ?? 0;
+ isUpdatingSearchText = false;
+ }
+
+ private void ResetSearchText()
+ {
+ StopLegacySearchResetTimer();
+ keyboardSearchText = string.Empty;
+ UpdateSearchTextBoxText(string.Empty);
+ }
+
+ private void RestartLegacySearchResetTimer()
+ {
+ if (!UseLegacySearch || string.IsNullOrEmpty(keyboardSearchText))
+ return;
+
+ legacySearchResetTimer.Stop();
+ legacySearchResetTimer.Start();
+ }
+
+ private void StopLegacySearchResetTimer()
+ {
+ legacySearchResetTimer.Stop();
+ }
+
+ private void UpdateLegacySearchPopupText(string text)
+ {
+ if (!UseLegacySearch || string.IsNullOrWhiteSpace(text))
+ {
+ HideLegacySearchPopup();
+ return;
+ }
+
+ legacyInputTextBlock.Text = text;
+
+ if (legacyInputPopup.PlacementTarget is null)
+ {
+ legacyInputPopup.PlacementTarget = this;
+ }
+
+ if (!legacyInputPopup.IsOpen)
+ {
+ legacyInputPopup.IsOpen = true;
+ }
+ }
+
+ private void HideLegacySearchPopup()
+ {
+ legacyInputTextBlock.Text = string.Empty;
+ legacyInputPopup.IsOpen = false;
+ }
+
+ private void OnLegacySearchResetTimerTick(object? sender, EventArgs e)
+ {
+ legacySearchResetTimer.Stop();
+
+ if (!UseLegacySearch || string.IsNullOrWhiteSpace(keyboardSearchText))
+ return;
+
+ ResetSearchText();
+ }
+
+ private void OnInputReceived(string input)
+ {
+ if (IsDropDownOpen)
+ {
+ if (UseLegacySearch)
{
- Dispatcher.UIThread.Post(() =>
+ var query = input.Trim();
+ if (string.IsNullOrWhiteSpace(query))
+ return;
+
+ var legacyMatch = FindLegacyMatch(query);
+ if (legacyMatch is not null)
{
- SelectedItem = foundEnum;
- });
+ Dispatcher.UIThread.Post(() =>
+ {
+ SelectedItem = legacyMatch;
+ ScrollIntoView(legacyMatch);
+ });
+ }
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(() => ApplyFilter(input));
+ }
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(input))
+ return;
+
+ if (searchCache.Get(input, out var cachedResult) && cachedResult is not null)
+ {
+ Dispatcher.UIThread.Post(() => SelectedItem = cachedResult);
+ return;
+ }
+
+ if (UseLegacySearch)
+ {
+ var legacyMatch = FindLegacyMatch(input);
+ if (legacyMatch is null)
+ return;
+
+ searchCache.Add(input, legacyMatch);
+ Dispatcher.UIThread.Post(() => SelectedItem = legacyMatch);
+ return;
+ }
+
+ object? found = null;
+
+ var enumBestMatch = FindBestMatch(input, Items.OfType(), e => e.GetStringValue());
+ if (enumBestMatch.Score > 50)
+ {
+ found = enumBestMatch.Item;
+ }
+ else
+ {
+ var modelBestMatch = FindBestMatch(input, Items.OfType(), m => GetItemSearchText(m));
+ if (modelBestMatch.Score > 50)
+ {
+ found = modelBestMatch.Item;
}
}
- else if (Items.OfType().ToList() is { } modelFiles)
+
+ if (found is not null)
{
- var found = modelFiles.FirstOrDefault(
- x => x.SearchText.StartsWith(input, StringComparison.OrdinalIgnoreCase)
- );
+ searchCache.Add(input, found);
+ Dispatcher.UIThread.Post(() => SelectedItem = found);
+ }
+ }
+
+ private void ApplyFilter(string input)
+ {
+ var query = input.Trim();
+ var filterChanged = !string.Equals(lastAppliedFilter, query, StringComparison.Ordinal);
+ lastAppliedFilter = query;
- if (found is not null)
+ var hasQuery = !string.IsNullOrWhiteSpace(query);
+ object? firstMatch = null;
+
+ foreach (var item in Items.Cast())
+ {
+ var isMatch = !hasQuery || IsItemMatch(item, query);
+ if (isMatch && firstMatch is null)
{
- Dispatcher.UIThread.Post(() =>
+ firstMatch = item;
+ }
+
+ if (ContainerFromItem(item) is not Control container)
+ continue;
+
+ container.IsVisible = isMatch;
+ }
+
+ if (!IsDropDownOpen || firstMatch is null)
+ {
+ return;
+ }
+
+ if (!filterChanged)
+ {
+ return;
+ }
+
+ // Keep the first matching result pinned near the top when virtualizing.
+ Dispatcher.UIThread.Post(() => ScrollIntoView(firstMatch), DispatcherPriority.Background);
+ }
+
+ private bool IsItemMatch(object item, string query)
+ {
+ var itemText = GetItemSearchText(item, UseLegacySearch);
+
+ if (UseLegacySearch)
+ {
+ return itemText.Contains(query, StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (itemText.Contains(query, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ // Allow approximate matching for typos while filtering.
+ return Fuzz.PartialRatio(query, itemText) >= 70;
+ }
+
+ private object? FindLegacyMatch(string query)
+ {
+ var trimmedQuery = query.Trim();
+ if (string.IsNullOrWhiteSpace(trimmedQuery))
+ return null;
+
+ object? firstSearchTextMatch = null;
+
+ foreach (var item in Items)
+ {
+ if (item is Enum enumItem)
+ {
+ if (enumItem.GetStringValue().Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase))
+ {
+ return enumItem;
+ }
+ }
+ else if (firstSearchTextMatch is null && item is ISearchText or ComfySampler or ComfyScheduler)
+ {
+ if (GetItemSearchText(item, true).Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase))
{
- SelectedItem = found;
- });
+ firstSearchTextMatch = item;
+ }
}
}
- Dispatcher.UIThread.Post(ResetPopupText);
+ return firstSearchTextMatch;
}
- private void UpdatePopupText(string text)
+ private static string GetItemSearchText(object item, bool useLegacySearch = false)
{
- inputTextBlock.Text += text; // Accumulate text in the popup
+ return item switch
+ {
+ HybridModelFile hybridModel => useLegacySearch
+ ? hybridModel.SearchText
+ : hybridModel.DetailedSearchText,
+ Enum enumItem => enumItem.GetStringValue(),
+ ComfySampler sampler => $"{sampler.DisplayName} {sampler.Name}",
+ ComfyScheduler scheduler => $"{scheduler.DisplayName} {scheduler.Name}",
+ ISearchText searchable => searchable.SearchText,
+ _ => item.ToString() ?? string.Empty,
+ };
+ }
- if (!inputPopup.IsOpen)
+ private static (TItem? Item, int Score) FindBestMatch(
+ string input,
+ IEnumerable items,
+ Func getSearchText
+ )
+ {
+ TItem? bestItem = default;
+ var bestScore = 0;
+
+ foreach (var item in items)
{
- inputPopup.IsOpen = true;
+ var score = Fuzz.WeightedRatio(input, getSearchText(item));
+ if (score <= bestScore)
+ continue;
+
+ bestScore = score;
+ bestItem = item;
+ }
+
+ return (bestItem, bestScore);
+ }
+
+ private void OnContainerPrepared(object? sender, ContainerPreparedEventArgs e)
+ {
+ if (!IsDropDownOpen || UseLegacySearch)
+ return;
+
+ var query = keyboardSearchText.Trim();
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ e.Container.IsVisible = true;
+ return;
+ }
+
+ if (e.Index >= 0 && e.Index < ItemsView.Count && ItemsView[e.Index] is { } item)
+ {
+ e.Container.IsVisible = IsItemMatch(item, query);
+ }
+ }
+
+ private void OnContainerIndexChanged(object? sender, ContainerIndexChangedEventArgs e)
+ {
+ if (!IsDropDownOpen || UseLegacySearch)
+ return;
+
+ var query = keyboardSearchText.Trim();
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ e.Container.IsVisible = true;
+ return;
+ }
+
+ if (e.NewIndex >= 0 && e.NewIndex < ItemsView.Count && ItemsView[e.NewIndex] is { } item)
+ {
+ e.Container.IsVisible = IsItemMatch(item, query);
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == ItemsSourceProperty)
+ {
+ searchCache.Clear();
+ }
+
+ if (change.Property == UseLegacySearchProperty && !UseLegacySearch)
+ {
+ StopLegacySearchResetTimer();
+ HideLegacySearchPopup();
+ }
+ }
+
+ private void ApplyGlobalLegacySearchOverride(bool globalOverride)
+ {
+ if (globalOverride)
+ {
+ SetValue(UseLegacySearchProperty, true);
+ }
+ else
+ {
+ ClearValue(UseLegacySearchProperty);
}
}
- private void ResetPopupText()
+ private void OnSettingsPropertyChanged(object? sender, RelayPropertyChangedEventArgs e)
{
- currentInput = string.Empty;
- inputTextBlock.Text = string.Empty;
- inputPopup.IsOpen = false;
+ if (e.PropertyName != nameof(Settings.UseLegacySearch) || settingsManager is null)
+ return;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ ApplyGlobalLegacySearchOverride(settingsManager.Settings.UseLegacySearch);
+ if (!UseLegacySearch)
+ {
+ StopLegacySearchResetTimer();
+ HideLegacySearchPopup();
+ }
+ });
}
- // Ensure proper disposal of resources
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
+
+ DropDownOpened -= OnDropDownOpened;
+ DropDownClosed -= OnDropDownClosed;
+ ContainerPrepared -= OnContainerPrepared;
+ ContainerIndexChanged -= OnContainerIndexChanged;
+
+ if (searchTextBox is not null)
+ {
+ searchTextBox.TextChanged -= SearchTextBoxOnTextChanged;
+ searchTextBox.KeyDown -= SearchTextBoxOnKeyDown;
+ }
+
+ if (settingsManager is not null)
+ {
+ settingsManager.SettingsPropertyChanged -= OnSettingsPropertyChanged;
+ }
+
+ legacySearchResetTimer.Tick -= OnLegacySearchResetTimerTick;
+ StopLegacySearchResetTimer();
+ HideLegacySearchPopup();
subscription.Dispose();
}
}
diff --git a/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs b/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
index 15014b36c..03513fcf6 100644
--- a/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
+++ b/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
@@ -99,6 +99,8 @@ static BetterContentDialog()
#endregion
private Border? backgroundPart;
+ private ContentDialogViewModelBase? boundDialogViewModel;
+ private ContentDialogProgressViewModelBase? boundProgressViewModel;
protected override Type StyleKeyOverride { get; } = typeof(ContentDialog);
@@ -154,8 +156,8 @@ public double MaxDialogWidth
public double MinDialogHeight
{
- get => GetValue(MaxDialogHeightProperty);
- set => SetValue(MaxDialogHeightProperty, value);
+ get => GetValue(MinDialogHeightProperty);
+ set => SetValue(MinDialogHeightProperty, value);
}
public static readonly StyledProperty MaxDialogHeightProperty = AvaloniaProperty.Register<
@@ -205,6 +207,7 @@ public BetterContentDialog()
}
AddHandler(LoadedEvent, OnLoaded);
+ AddHandler(UnloadedEvent, OnUnloaded);
}
///
@@ -283,23 +286,47 @@ private void TrySetButtonCommands()
private void TryBindButtonEvents()
{
+ UnbindButtonEvents();
+
if ((Content as Control)?.DataContext is ContentDialogViewModelBase viewModel)
{
viewModel.PrimaryButtonClick += OnDialogButtonClick;
viewModel.SecondaryButtonClick += OnDialogButtonClick;
viewModel.CloseButtonClick += OnDialogButtonClick;
+ boundDialogViewModel = viewModel;
}
else if (Content is ContentDialogViewModelBase viewModelDirect)
{
viewModelDirect.PrimaryButtonClick += OnDialogButtonClick;
viewModelDirect.SecondaryButtonClick += OnDialogButtonClick;
viewModelDirect.CloseButtonClick += OnDialogButtonClick;
+ boundDialogViewModel = viewModelDirect;
}
else if ((Content as Control)?.DataContext is ContentDialogProgressViewModelBase progressViewModel)
{
progressViewModel.PrimaryButtonClick += OnDialogButtonClick;
progressViewModel.SecondaryButtonClick += OnDialogButtonClick;
progressViewModel.CloseButtonClick += OnDialogButtonClick;
+ boundProgressViewModel = progressViewModel;
+ }
+ }
+
+ private void UnbindButtonEvents()
+ {
+ if (boundDialogViewModel is not null)
+ {
+ boundDialogViewModel.PrimaryButtonClick -= OnDialogButtonClick;
+ boundDialogViewModel.SecondaryButtonClick -= OnDialogButtonClick;
+ boundDialogViewModel.CloseButtonClick -= OnDialogButtonClick;
+ boundDialogViewModel = null;
+ }
+
+ if (boundProgressViewModel is not null)
+ {
+ boundProgressViewModel.PrimaryButtonClick -= OnDialogButtonClick;
+ boundProgressViewModel.SecondaryButtonClick -= OnDialogButtonClick;
+ boundProgressViewModel.CloseButtonClick -= OnDialogButtonClick;
+ boundProgressViewModel = null;
}
}
@@ -406,4 +433,9 @@ private void OnLoaded(object? sender, RoutedEventArgs? e)
Dispatcher.UIThread.InvokeAsync(viewModel.OnLoadedAsync).SafeFireAndForget();
}*/
}
+
+ private void OnUnloaded(object? sender, RoutedEventArgs? e)
+ {
+ UnbindButtonEvents();
+ }
}
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/ExtraNetworkCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/ExtraNetworkCard.axaml
index 0c8bd3afe..620a86272 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/ExtraNetworkCard.axaml
+++ b/StabilityMatrix.Avalonia/Controls/Inference/ExtraNetworkCard.axaml
@@ -4,6 +4,7 @@
xmlns:avalonia="https://github.com/projektanker/icons.avalonia"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:fluent="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
@@ -22,9 +23,9 @@
+ RowDefinitions="Auto,Auto,Auto,Auto,Auto">
+
+
+
+
+
+ IsVisible="True">
@@ -44,359 +41,354 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ColumnDefinitions="70,*,Auto"
+ IsVisible="{Binding IsRefinerSelectionEnabled}">
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ Margin="90,4,0,0"
+ FontSize="11"
+ Foreground="{DynamicResource TextFillColorSecondaryBrush}"
+ IsVisible="{Binding ShowWorkflowProfileStatus}"
+ Text="{Binding WorkflowProfileStatusText}" />
-
-
+
+ ColumnDefinitions="70,*"
+ IsVisible="{Binding ShowEncoderTypeSelection}">
+
+
+
-
+
-
-
-
+ Header="{Binding AdvancedOptionsHeader}"
+ IsExpanded="{Binding IsAdvancedOptionsExpanded}"
+ IsVisible="{Binding HasActiveAdvancedOptions}">
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+ IsExpanded="{Binding IsTextEncodersExpanded}"
+ IsVisible="{Binding ShowEncoderSection}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
-
+
-
-
-
+ ColumnDefinitions="90,*"
+ IsVisible="{Binding IsModelLoaderSelectionEnabled}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/RegionalPromptCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/RegionalPromptCard.axaml
new file mode 100644
index 000000000..151d5cd2f
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Controls/Inference/RegionalPromptCard.axaml
@@ -0,0 +1,105 @@
+ο»Ώ
+
+
+
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/RegionalPromptCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/Inference/RegionalPromptCard.axaml.cs
new file mode 100644
index 000000000..322f2aef7
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Controls/Inference/RegionalPromptCard.axaml.cs
@@ -0,0 +1,6 @@
+using Injectio.Attributes;
+
+namespace StabilityMatrix.Avalonia.Controls;
+
+[RegisterTransient]
+public class RegionalPromptCard : TemplatedControlBase;
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/SamplerCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/SamplerCard.axaml
index 5cabb0b4d..5d9e7dac6 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/SamplerCard.axaml
+++ b/StabilityMatrix.Avalonia/Controls/Inference/SamplerCard.axaml
@@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="https://github.com/projektanker/icons.avalonia"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
+ xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Runtime"
xmlns:generic1="clr-namespace:System.Collections.Generic;assembly=System.Collections"
xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia"
@@ -46,7 +47,8 @@
DisplayMemberBinding="{Binding DisplayName}"
IsVisible="{Binding IsSamplerSelectionEnabled}"
ItemsSource="{Binding ClientManager.Samplers}"
- SelectedItem="{Binding SelectedSampler}" />
+ SelectedItem="{Binding SelectedSampler}"
+ UseLegacySearch="True" />
+ SelectedItem="{Binding SelectedScheduler}"
+ UseLegacySearch="True" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml.cs
index 71b8bd7bf..a0725f9cf 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml.cs
+++ b/StabilityMatrix.Avalonia/Controls/Inference/SelectImageCard.axaml.cs
@@ -50,7 +50,10 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
}
}
- vm.CurrentBitmapSize = System.Drawing.Size.Empty;
+ if (vm.ImageSource is null)
+ {
+ vm.CurrentBitmapSize = System.Drawing.Size.Empty;
+ }
});
}
}
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml.cs
index cc635d7a3..2153f2ae8 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml.cs
+++ b/StabilityMatrix.Avalonia/Controls/Inference/StackEditableCard.axaml.cs
@@ -18,6 +18,7 @@ namespace StabilityMatrix.Avalonia.Controls;
public class StackEditableCard : TemplatedControlBase
{
private ListBox? listBoxPart;
+ private Button? addButtonPart;
// ReSharper disable once MemberCanBePrivate.Global
public static readonly StyledProperty IsListBoxEditEnabledProperty = AvaloniaProperty.Register<
@@ -51,10 +52,8 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
};
}
- if (e.NameScope.Find("PART_AddButton") is { } addButton)
- {
- addButton.Flyout = GetAddButtonFlyout();
- }
+ addButtonPart = e.NameScope.Find("PART_AddButton");
+ ConfigureAddButton();
}
///
@@ -83,9 +82,36 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
}
}
- private FAMenuFlyout GetAddButtonFlyout()
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ ConfigureAddButton();
+ }
+
+ private void ConfigureAddButton()
+ {
+ if (addButtonPart is null || DataContext is not StackEditableCardViewModel vm)
+ return;
+
+ addButtonPart.Command = null;
+ addButtonPart.CommandParameter = null;
+ addButtonPart.Flyout = null;
+
+ if (vm.AvailableModules.Count == 1)
+ {
+ addButtonPart.Command = vm.AddModuleCommand;
+ addButtonPart.CommandParameter = vm.AvailableModules[0];
+ return;
+ }
+
+ if (vm.AvailableModules.Count > 1)
+ {
+ addButtonPart.Flyout = GetAddButtonFlyout(vm);
+ }
+ }
+
+ private FAMenuFlyout GetAddButtonFlyout(StackEditableCardViewModel vm)
{
- var vm = (DataContext as StackEditableCardViewModel)!;
var flyout = new FAMenuFlyout();
foreach (var moduleType in vm.AvailableModules)
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/WanModelCard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/WanModelCard.axaml
index 9a205929b..473746f97 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/WanModelCard.axaml
+++ b/StabilityMatrix.Avalonia/Controls/Inference/WanModelCard.axaml
@@ -37,7 +37,7 @@
@@ -57,6 +57,16 @@
SelectedItem="{Binding SelectedModel}"
Theme="{StaticResource BetterComboBoxHybridModelTheme}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Grid.ColumnSpan="3">
diff --git a/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs b/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs
index 773cb3eef..9f8797816 100644
--- a/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs
+++ b/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs
@@ -1,4 +1,4 @@
-ο»Ώusing System.IO;
+using System.IO;
using Avalonia;
using Avalonia.Controls.Primitives;
using Markdig;
@@ -50,7 +50,7 @@ private void ParseText(string value)
if (string.IsNullOrWhiteSpace(value))
return;
- var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
+ var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().UseEmojiAndSmiley().Build();
var html =
$"""{Markdig.Markdown.ToHtml(value, pipeline)}""";
Html = html;
diff --git a/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs b/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs
index ba830c7f9..877ded502 100644
--- a/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs
+++ b/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs
@@ -1,19 +1,262 @@
-ο»Ώusing System.Collections.Generic;
+ο»Ώusing System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
using System.Text.Json.Serialization;
using SkiaSharp;
using StabilityMatrix.Core.Converters.Json;
namespace StabilityMatrix.Avalonia.Controls.Models;
+///
+/// Type of path - determines how the path is rendered.
+///
+public enum PenPathType
+{
+ ///
+ /// Freehand brush strokes (default).
+ ///
+ Freehand,
+
+ ///
+ /// Filled rectangle shape.
+ ///
+ Rectangle,
+
+ ///
+ /// Filled ellipse/oval shape.
+ ///
+ Ellipse,
+
+ ///
+ /// Bitmap image (used for flood fill results).
+ ///
+ Bitmap,
+}
+
+///
+/// Custom JSON converter for PenPath that handles both legacy (JSON array)
+/// and new (compressed base64 string) formats for backwards compatibility.
+///
+public class PenPathJsonConverter : JsonConverter
+{
+ public override PenPath Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ return default;
+
+ var penPath = new PenPath();
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ return penPath;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ continue;
+
+ var propertyName = reader.GetString()?.ToLowerInvariant();
+ reader.Read();
+
+ switch (propertyName)
+ {
+ case "points":
+ // Handle both legacy (array) and new (compressed string) formats
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ // New compressed format
+ var compressed = reader.GetString();
+ var decompressedPoints = PenPath.DecompressPointsPublic(compressed);
+ penPath = penPath with { Points = decompressedPoints ?? [] };
+ }
+ else if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ // Legacy format - manually deserialize array of PenPoint objects
+ // (Can't use JsonSerializer.Deserialize due to source-gen context limitations)
+ var points = new List();
+ var penPointConverter = new PenPointJsonConverter();
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ break;
+
+ if (reader.TokenType == JsonTokenType.StartObject)
+ {
+ var point = penPointConverter.Read(ref reader, typeof(PenPoint), options);
+ points.Add(point);
+ }
+ }
+
+ penPath = penPath with { Points = points };
+ }
+ break;
+
+ case "fillcolor":
+ var colorConverter = new SKColorJsonConverter();
+ var color = colorConverter.Read(ref reader, typeof(SKColor), options);
+ penPath = penPath with { FillColor = color };
+ break;
+
+ case "iserase":
+ penPath = penPath with { IsErase = reader.GetBoolean() };
+ break;
+
+ case "pathtype":
+ // Handle both string and number formats for backward compatibility
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ if (Enum.TryParse(reader.GetString(), out var pathType))
+ penPath = penPath with { PathType = pathType };
+ }
+ else if (reader.TokenType == JsonTokenType.Number)
+ {
+ var pathTypeInt = reader.GetInt32();
+ if (Enum.IsDefined(typeof(PenPathType), pathTypeInt))
+ penPath = penPath with { PathType = (PenPathType)pathTypeInt };
+ }
+ break;
+
+ case "bounds":
+ var rectConverter = new SKRectJsonConverter();
+ var bounds = rectConverter.Read(ref reader, typeof(SKRect), options);
+ penPath = penPath with { Bounds = bounds };
+ break;
+
+ case "isstrokeonly":
+ penPath = penPath with { IsStrokeOnly = reader.GetBoolean() };
+ break;
+
+ case "strokewidth":
+ penPath = penPath with { StrokeWidth = (float)reader.GetDouble() };
+ break;
+
+ case "radius":
+ penPath = penPath with { Radius = (float)reader.GetDouble() };
+ break;
+
+ case "feathering":
+ penPath = penPath with { Feathering = (float)reader.GetDouble() };
+ break;
+
+ case "bitmapdata":
+ var base64 = reader.GetString();
+ if (!string.IsNullOrEmpty(base64))
+ {
+ var bytes = Convert.FromBase64String(base64);
+ var bmp = SKBitmap.Decode(bytes);
+ if (bmp is not null)
+ {
+ penPath = penPath with { BitmapData = bmp };
+ }
+ }
+ break;
+
+ default:
+ reader.Skip();
+ break;
+ }
+ }
+
+ return penPath;
+ }
+
+ public override void Write(Utf8JsonWriter writer, PenPath value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+
+ // Write FillColor
+ var colorConverter = new SKColorJsonConverter();
+ writer.WritePropertyName("fillColor");
+ colorConverter.Write(writer, value.FillColor, options);
+
+ writer.WriteBoolean("isErase", value.IsErase);
+ writer.WriteString("pathType", value.PathType.ToString());
+
+ // Write Bounds
+ var rectConverter = new SKRectJsonConverter();
+ writer.WritePropertyName("bounds");
+ rectConverter.Write(writer, value.Bounds, options);
+
+ writer.WriteBoolean("isStrokeOnly", value.IsStrokeOnly);
+ writer.WriteNumber("strokeWidth", value.StrokeWidth);
+ writer.WriteNumber("radius", value.Radius);
+ writer.WriteNumber("feathering", value.Feathering);
+
+ // Write points in compressed format
+ var compressedPoints = PenPath.CompressPointsPublic(value.Points);
+ if (compressedPoints != null)
+ {
+ writer.WriteString("points", compressedPoints);
+ }
+
+ // Write bitmap data (for flood fill paths) as PNG base64
+ if (value.BitmapData is { } bitmap)
+ {
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Png, 100);
+ writer.WriteString("bitmapData", Convert.ToBase64String(data.AsSpan()));
+ }
+
+ writer.WriteEndObject();
+ }
+}
+
+[JsonConverter(typeof(PenPathJsonConverter))]
public readonly record struct PenPath()
{
- [JsonConverter(typeof(SKColorJsonConverter))]
public SKColor FillColor { get; init; }
public bool IsErase { get; init; }
+ ///
+ /// Type of path (Freehand, Rectangle, or Ellipse).
+ ///
+ public PenPathType PathType { get; init; } = PenPathType.Freehand;
+
+ ///
+ /// Bounding rectangle for shape paths (Rectangle, Ellipse).
+ /// For Freehand paths, this is ignored.
+ ///
+ public SKRect Bounds { get; init; }
+
+ ///
+ /// If true, draws shape outline only (stroke). If false, fills the shape.
+ /// Only applies to Rectangle and Ellipse path types.
+ ///
+ public bool IsStrokeOnly { get; init; }
+
+ ///
+ /// Stroke width for stroke-only shapes. Only used when IsStrokeOnly is true.
+ ///
+ public float StrokeWidth { get; init; } = 5f;
+
+ ///
+ /// Brush radius for this stroke. All points in the stroke share this radius.
+ ///
+ public float Radius { get; init; }
+
+ ///
+ /// Feathering amount for soft brush edges. 0 = hard edge, 1 = fully soft/blurred.
+ /// The blur radius is calculated as: effectiveRadius * feathering.
+ ///
+ public float Feathering { get; init; }
+
+ ///
+ /// Points for rendering. Serialization is handled by the custom JsonConverter.
+ ///
+ [JsonIgnore]
public List Points { get; init; } = [];
+ ///
+ /// Bitmap data for flood fill paths.
+ ///
+ [JsonIgnore]
+ public SKBitmap? BitmapData { get; init; }
+
public SKPath ToSKPath()
{
var skPath = new SKPath();
@@ -34,4 +277,126 @@ public SKPath ToSKPath()
return skPath;
}
+
+ ///
+ /// Gets the effective radius for rendering. Returns Radius if set, otherwise falls back to first point's radius for backward compatibility.
+ ///
+ public float GetEffectiveRadius()
+ {
+ if (Radius > 0)
+ return Radius;
+
+ // Backward compatibility: check first point
+ if (Points.Count > 0 && Points[0].Radius > 0)
+ return (float)Points[0].Radius;
+
+ return 1f; // Default fallback
+ }
+
+ ///
+ /// Compresses points to a base64-encoded gzip string. Public for use by JsonConverter.
+ ///
+ public static string? CompressPointsPublic(List points)
+ {
+ if (points.Count == 0)
+ return null;
+
+ // Calculate buffer size: 4 bytes count + 12 bytes per point (3 floats: x, y, pressure)
+ var bufferSize = 4 + (points.Count * 12);
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
+
+ try
+ {
+ var offset = 0;
+
+ // Write point count
+ BitConverter.TryWriteBytes(buffer.AsSpan(offset), points.Count);
+ offset += 4;
+
+ // Write each point as 3 floats
+ foreach (var point in points)
+ {
+ BitConverter.TryWriteBytes(buffer.AsSpan(offset), (float)point.X);
+ offset += 4;
+ BitConverter.TryWriteBytes(buffer.AsSpan(offset), (float)point.Y);
+ offset += 4;
+ BitConverter.TryWriteBytes(buffer.AsSpan(offset), (float)(point.Pressure ?? 1.0));
+ offset += 4;
+ }
+
+ // Compress with gzip
+ using var outputStream = new MemoryStream();
+ using (var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ gzipStream.Write(buffer, 0, offset);
+ }
+
+ return Convert.ToBase64String(outputStream.ToArray());
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ ///
+ /// Decompresses points from a base64-encoded gzip string. Public for use by JsonConverter.
+ ///
+ public static List? DecompressPointsPublic(string? compressed)
+ {
+ if (string.IsNullOrEmpty(compressed))
+ return null;
+
+ try
+ {
+ var compressedBytes = Convert.FromBase64String(compressed);
+
+ using var inputStream = new MemoryStream(compressedBytes);
+ using var gzipStream = new GZipStream(inputStream, CompressionMode.Decompress);
+ using var outputStream = new MemoryStream();
+
+ gzipStream.CopyTo(outputStream);
+ var buffer = outputStream.ToArray();
+
+ if (buffer.Length < 4)
+ return null;
+
+ var offset = 0;
+
+ // Read point count
+ var count = BitConverter.ToInt32(buffer, offset);
+ offset += 4;
+
+ // Validate we have enough data
+ if (buffer.Length < 4 + (count * 12))
+ return null;
+
+ var points = new List(count);
+
+ for (var i = 0; i < count; i++)
+ {
+ var x = BitConverter.ToSingle(buffer, offset);
+ offset += 4;
+ var y = BitConverter.ToSingle(buffer, offset);
+ offset += 4;
+ var pressure = BitConverter.ToSingle(buffer, offset);
+ offset += 4;
+
+ points.Add(
+ new PenPoint(x, y)
+ {
+ Pressure = pressure >= 0 && pressure <= 1 ? pressure : null,
+ IsPen = true, // Mark as pen point so it renders correctly
+ }
+ );
+ }
+
+ return points;
+ }
+ catch
+ {
+ // If decompression fails, return null (caller will handle as legacy format)
+ return null;
+ }
+ }
}
diff --git a/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs b/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs
index b3f004492..de6ad27bc 100644
--- a/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs
+++ b/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs
@@ -1,8 +1,118 @@
ο»Ώusing System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
using SkiaSharp;
namespace StabilityMatrix.Avalonia.Controls.Models;
+///
+/// Custom JSON converter for PenPoint to handle serialization of ulong coordinates
+/// and legacy double-based formats.
+///
+public class PenPointJsonConverter : JsonConverter
+{
+ public override PenPoint Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ return default;
+
+ ulong x = 0;
+ ulong y = 0;
+ double? pressure = null;
+ double radius = 1; // Default radius, legacy format stored per-point
+ bool isPen = true; // Default to true for rendering
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType == JsonTokenType.PropertyName)
+ {
+ var propertyName = reader.GetString();
+ reader.Read();
+
+ switch (propertyName?.ToLowerInvariant())
+ {
+ case "x":
+ // Handle both double and ulong formats
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ if (reader.TryGetUInt64(out var ulongX))
+ x = ulongX;
+ else if (reader.TryGetDouble(out var doubleX))
+ x = Convert.ToUInt64(doubleX);
+ }
+ break;
+
+ case "y":
+ // Handle both double and ulong formats
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ if (reader.TryGetUInt64(out var ulongY))
+ y = ulongY;
+ else if (reader.TryGetDouble(out var doubleY))
+ y = Convert.ToUInt64(doubleY);
+ }
+ break;
+
+ case "pressure":
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ pressure = reader.GetDouble();
+ }
+ break;
+
+ case "ispen":
+ // Legacy format had IsPen serialized - read it but we'll set true anyway
+ if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False)
+ {
+ isPen = reader.GetBoolean();
+ }
+ break;
+
+ case "radius":
+ // Legacy format had Radius on each point - read it for backward compatibility
+ // GetEffectiveRadius() on PenPath will check Points[0].Radius as fallback
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ radius = reader.GetDouble();
+ }
+ break;
+
+ default:
+ reader.Skip();
+ break;
+ }
+ }
+ }
+
+ return new PenPoint(x, y)
+ {
+ Pressure = pressure,
+ IsPen = isPen,
+ Radius = radius,
+ };
+ }
+
+ public override void Write(Utf8JsonWriter writer, PenPoint value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("x", value.X);
+ writer.WriteNumber("y", value.Y);
+ if (value.Pressure.HasValue)
+ {
+ writer.WriteNumber("pressure", value.Pressure.Value);
+ }
+ writer.WriteEndObject();
+ }
+}
+
+[JsonConverter(typeof(PenPointJsonConverter))]
public readonly record struct PenPoint(ulong X, ulong Y)
{
public PenPoint(double x, double y)
@@ -14,6 +124,10 @@ public PenPoint(SKPoint skPoint)
///
/// Radius of the point.
///
+ ///
+ /// Legacy property for backward compatibility. New paths store Radius at the PenPath level.
+ ///
+ [JsonIgnore]
public double Radius { get; init; } = 1;
///
@@ -24,6 +138,10 @@ public PenPoint(SKPoint skPoint)
///
/// True if the point was created by a pen, false if it was created by a mouse.
///
+ ///
+ /// Runtime-only property for pressure-sensitive rendering. Not persisted.
+ ///
+ [JsonIgnore]
public bool IsPen { get; init; }
public SKPoint ToSKPoint() => new(X, Y);
diff --git a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml
index cfd62d17a..59b32a321 100644
--- a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml
+++ b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml
@@ -2,19 +2,18 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
+ xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent"
xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
+ xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:vmControls="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Controls"
- xmlns:faIcons="https://github.com/projektanker/icons.avalonia"
- xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
- xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
x:DataType="vmControls:PaintCanvasViewModel">
-
+
@@ -23,11 +22,11 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
diff --git a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs
index 773213500..2a960ed0f 100644
--- a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs
+++ b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs
@@ -35,8 +35,6 @@ private ImmutableList Paths
private IDisposable? viewModelSubscription;
- private bool isPenDown;
-
private PaintCanvasViewModel? ViewModel { get; set; }
private SkiaCustomCanvas? MainCanvas { get; set; }
@@ -134,9 +132,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
{
var newIsEnabled = change.GetNewValue();
- if (!newIsEnabled)
+ if (!newIsEnabled && ViewModel is { } vm)
{
- isPenDown = false;
+ vm.IsPenDown = false;
}
// On any enabled change, flush temporary paths
@@ -164,9 +162,27 @@ private void HandlePointerEvent(PointerEventArgs e)
return;
}
+ if (ViewModel is not { } vm)
+ {
+ return;
+ }
+
+ // Handle Move tool separately - it doesn't require drawing to be enabled
+ if (vm.IsMoveTool)
+ {
+ HandleMoveToolEvent(e, vm);
+ return;
+ }
+
+ if (!vm.IsDrawingEnabled)
+ {
+ return;
+ }
+
if (e.RoutedEvent == PointerReleasedEvent && e.Pointer.Type == PointerType.Touch)
{
TemporaryPaths.TryRemove(e.Pointer.Id, out _);
+ vm.CancelShapeDrawing();
return;
}
@@ -176,11 +192,6 @@ private void HandlePointerEvent(PointerEventArgs e)
// https://github.com/AvaloniaUI/Avalonia/issues/12289#issuecomment-1695620412
e.PreventGestureRecognition();
- if (DataContext is not PaintCanvasViewModel viewModel)
- {
- return;
- }
-
var currentPoint = e.GetCurrentPoint(this);
if (e.RoutedEvent == PointerPressedEvent)
@@ -191,35 +202,115 @@ private void HandlePointerEvent(PointerEventArgs e)
return;
}
- isPenDown = true;
+ vm.IsPenDown = true;
- HandlePointerMoved(e);
+ if (vm.SelectedTool == PaintCanvasTool.PaintBucket)
+ {
+ // Paint bucket: perform flood fill on click
+ var position = e.GetPosition(MainCanvas);
+ var fillColor = vm.PaintBrushSKColor.WithAlpha((byte)(vm.PaintBrushAlpha * 255));
+ vm.FloodFillAt(new SKPoint((float)position.X, (float)position.Y), fillColor);
+ vm.IsPenDown = false;
+ }
+ else if (vm.IsShapeTool)
+ {
+ var position = e.GetPosition(MainCanvas);
+ vm.StartShapeDrawing(new SKPoint((float)position.X, (float)position.Y), e.Pointer.Id);
+ }
+ else
+ {
+ HandlePointerMoved(e);
+ }
}
else if (e.RoutedEvent == PointerReleasedEvent)
{
- if (isPenDown)
+ if (vm.IsPenDown)
{
- HandlePointerMoved(e);
+ if (vm.IsShapeTool && vm.ShapeStartPoint.HasValue)
+ {
+ var endPoint = e.GetPosition(MainCanvas);
+ vm.FinalizeShape(new SKPoint((float)endPoint.X, (float)endPoint.Y));
+ }
+ else
+ {
+ HandlePointerMoved(e);
+ }
- isPenDown = false;
+ vm.IsPenDown = false;
}
- if (TemporaryPaths.TryGetValue(e.Pointer.Id, out var path))
+ if (!vm.IsShapeTool && TemporaryPaths.TryGetValue(e.Pointer.Id, out var path))
{
Paths = Paths.Add(path);
+ vm.ClearRedoStack(); // New path added, clear redo history
}
- TemporaryPaths.TryRemove(e.Pointer.Id, out _);
+ if (!vm.IsShapeTool)
+ {
+ TemporaryPaths.TryRemove(e.Pointer.Id, out _);
+ }
}
else
{
// Moved event
- if (!isPenDown || currentPoint.Properties.Pressure == 0)
+ if (!vm.IsPenDown)
{
return;
}
- HandlePointerMoved(e);
+ if (vm.IsShapeTool && vm.ShapeStartPoint.HasValue)
+ {
+ var endPoint = e.GetPosition(MainCanvas);
+ vm.UpdateShapePreview(new SKPoint((float)endPoint.X, (float)endPoint.Y));
+ }
+ else if (currentPoint.Properties.Pressure != 0)
+ {
+ HandlePointerMoved(e);
+ }
+ }
+
+ Dispatcher.UIThread.Post(() => MainCanvas?.InvalidateVisual(), DispatcherPriority.Render);
+ }
+
+ private void HandleMoveToolEvent(PointerEventArgs e, PaintCanvasViewModel vm)
+ {
+ e.Handled = true;
+ e.PreventGestureRecognition();
+
+ var currentPoint = e.GetCurrentPoint(this);
+
+ if (e.RoutedEvent == PointerPressedEvent)
+ {
+ // Ignore if mouse and not left button
+ if (e.Pointer.Type == PointerType.Mouse && !currentPoint.Properties.IsLeftButtonPressed)
+ {
+ return;
+ }
+
+ vm.IsPenDown = true;
+ var position = e.GetPosition(MainCanvas);
+ // Get current offsets from the callback, or use (0, 0) if not set
+ var currentOffset = vm.GetCurrentMoveOffset?.Invoke() ?? (0, 0);
+ vm.StartMove(new SKPoint((float)position.X, (float)position.Y), currentOffset.X, currentOffset.Y);
+ }
+ else if (e.RoutedEvent == PointerReleasedEvent)
+ {
+ if (vm.IsPenDown)
+ {
+ vm.EndMove();
+ vm.IsPenDown = false;
+ }
+ }
+ else
+ {
+ // Moved event
+ if (!vm.IsPenDown || !vm.MoveStartPoint.HasValue)
+ {
+ return;
+ }
+
+ var position = e.GetPosition(MainCanvas);
+ vm.UpdateMove(new SKPoint((float)position.X, (float)position.Y));
}
Dispatcher.UIThread.Post(() => MainCanvas?.InvalidateVisual(), DispatcherPriority.Render);
@@ -235,8 +326,6 @@ private void HandlePointerMoved(PointerEventArgs e)
// Use intermediate points to include past events we missed
var points = e.GetIntermediatePoints(MainCanvas);
- Debug.WriteLine($"Points: {string.Join(",", points.Select(p => p.Position.ToString()))}");
-
if (points.Count == 0)
{
return;
@@ -250,7 +339,9 @@ private void HandlePointerMoved(PointerEventArgs e)
penPath = new PenPath
{
FillColor = viewModel.PaintBrushSKColor.WithAlpha((byte)(viewModel.PaintBrushAlpha * 255)),
- IsErase = viewModel.SelectedTool == PaintCanvasTool.Eraser
+ IsErase = viewModel.SelectedTool == PaintCanvasTool.Eraser,
+ Radius = (float)viewModel.PaintBrushSize,
+ Feathering = (float)viewModel.PaintBrushFeathering,
};
TemporaryPaths[e.Pointer.Id] = penPath;
}
@@ -275,7 +366,7 @@ private void HandlePointerMoved(PointerEventArgs e)
{
Pressure = point.Pointer.Type == PointerType.Mouse ? null : point.Properties.Pressure,
Radius = viewModel.PaintBrushSize,
- IsPen = point.Pointer.Type == PointerType.Pen
+ IsPen = point.Pointer.Type == PointerType.Pen,
};
penPath.Points.Add(penPoint);
@@ -311,7 +402,113 @@ protected override void OnKeyDown(KeyEventArgs e)
if (e.Key == Key.Escape)
{
e.Handled = true;
+ return;
+ }
+
+ // Keyboard shortcuts for paint canvas
+ if (ViewModel is not { } vm)
+ return;
+
+ // Check for modifier keys
+ var isCtrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
+
+ // Ctrl+Z: Undo
+ if (isCtrl && e.Key == Key.Z && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ if (vm.UndoCommand.CanExecute(null))
+ {
+ vm.UndoCommand.Execute(null);
+ RefreshCanvas();
+ }
+ e.Handled = true;
+ return;
+ }
+
+ // Ctrl+Y or Ctrl+Shift+Z: Redo
+ if (
+ (isCtrl && e.Key == Key.Y)
+ || (isCtrl && e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Z)
+ )
+ {
+ if (vm.RedoCommand.CanExecute(null))
+ {
+ vm.RedoCommand.Execute(null);
+ RefreshCanvas();
+ }
+ e.Handled = true;
+ return;
}
+
+ // Arrow key nudging when Move tool is selected
+ if (vm.IsMoveTool)
+ {
+ var nudgeAmount = e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? 10.0 : 1.0;
+ double deltaX = 0,
+ deltaY = 0;
+
+ switch (e.Key)
+ {
+ case Key.Left:
+ deltaX = -nudgeAmount;
+ break;
+ case Key.Right:
+ deltaX = nudgeAmount;
+ break;
+ case Key.Up:
+ deltaY = -nudgeAmount;
+ break;
+ case Key.Down:
+ deltaY = nudgeAmount;
+ break;
+ }
+
+ if (deltaX != 0 || deltaY != 0)
+ {
+ // Get current offset, apply delta, and invoke callback
+ var currentOffset = vm.GetCurrentMoveOffset?.Invoke() ?? (0, 0);
+ vm.OnMoveToolDrag?.Invoke(currentOffset.X + deltaX, currentOffset.Y + deltaY);
+ RefreshCanvas();
+ e.Handled = true;
+ return;
+ }
+ }
+
+ // Skip tool shortcuts if modifiers are held (to not interfere with other shortcuts)
+ // But allow Shift for arrow key nudging (handled above)
+ if (e.KeyModifiers != KeyModifiers.None && e.KeyModifiers != KeyModifiers.Shift)
+ return;
+
+ switch (e.Key)
+ {
+ case Key.B:
+ vm.SelectBrushToolCommand.Execute(null);
+ break;
+ case Key.E:
+ vm.SelectEraserToolCommand.Execute(null);
+ break;
+ case Key.R:
+ vm.SelectRectangleToolCommand.Execute(null);
+ break;
+ case Key.O:
+ vm.SelectEllipseToolCommand.Execute(null);
+ break;
+ case Key.OemOpenBrackets:
+ vm.DecreaseBrushSizeCommand.Execute(null);
+ break;
+ case Key.OemCloseBrackets:
+ vm.IncreaseBrushSizeCommand.Execute(null);
+ break;
+ case Key.G:
+ vm.SelectPaintBucketToolCommand.Execute(null);
+ break;
+ case Key.V:
+ vm.SelectMoveToolCommand.Execute(null);
+ break;
+ default:
+ return;
+ }
+ UpdateCanvasCursor();
+ e.Handled = true;
}
///
@@ -340,6 +537,7 @@ private void UpdateMainCanvasBounds()
private int lastCanvasCursorRadius;
private Cursor? lastCanvasCursor;
+ private PaintCanvasTool? lastCanvasCursorTool;
private void UpdateCanvasCursor()
{
@@ -348,6 +546,39 @@ private void UpdateCanvasCursor()
return;
}
+ var selectedTool = ViewModel?.SelectedTool ?? PaintCanvasTool.PaintBrush;
+
+ // Use crosshair for shape tools and paint bucket
+ if (
+ selectedTool
+ is PaintCanvasTool.Rectangle
+ or PaintCanvasTool.Ellipse
+ or PaintCanvasTool.PaintBucket
+ )
+ {
+ if (lastCanvasCursorTool != selectedTool)
+ {
+ lastCanvasCursor?.Dispose();
+ lastCanvasCursor = new Cursor(StandardCursorType.Cross);
+ lastCanvasCursorTool = selectedTool;
+ }
+ canvas.Cursor = lastCanvasCursor;
+ return;
+ }
+
+ // Use SizeAll (move) cursor for Move tool
+ if (selectedTool == PaintCanvasTool.Move)
+ {
+ if (lastCanvasCursorTool != selectedTool)
+ {
+ lastCanvasCursor?.Dispose();
+ lastCanvasCursor = new Cursor(StandardCursorType.SizeAll);
+ lastCanvasCursorTool = selectedTool;
+ }
+ canvas.Cursor = lastCanvasCursor;
+ return;
+ }
+
var currentZoom = ViewModel?.CurrentZoom ?? 1;
// Get brush size
@@ -355,13 +586,14 @@ private void UpdateCanvasCursor()
var brushRadius = (int)Math.Ceiling(currentBrushSize * 2 * currentZoom);
// Only update cursor if brush size has changed
- if (brushRadius == lastCanvasCursorRadius)
+ if (brushRadius == lastCanvasCursorRadius && lastCanvasCursorTool == selectedTool)
{
canvas.Cursor = lastCanvasCursor;
return;
}
lastCanvasCursorRadius = brushRadius;
+ lastCanvasCursorTool = selectedTool;
var brushDiameter = brushRadius * 2;
@@ -386,7 +618,7 @@ private void UpdateCanvasCursor()
StrokeCap = SKStrokeCap.Round,
StrokeJoin = SKStrokeJoin.Round,
IsDither = true,
- IsAntialias = true
+ IsAntialias = true,
}
);
cursorCanvas.Flush();
@@ -415,26 +647,6 @@ private void MainCanvas_OnPointerExited(object? sender, PointerEventArgs e)
}
}
- private Point GetRelativePosition(Point pt, Visual? relativeTo)
- {
- if (VisualRoot is not Visual visualRoot)
- return default;
- if (relativeTo == null)
- return pt;
-
- return pt * visualRoot.TransformToVisual(relativeTo) ?? default;
- }
-
- public AsyncRelayCommand ClearCanvasCommand => new(ClearCanvasAsync);
-
- public async Task ClearCanvasAsync()
- {
- Paths = ImmutableList.Empty;
- TemporaryPaths.Clear();
-
- await Dispatcher.UIThread.InvokeAsync(() => MainCanvas?.InvalidateVisual());
- }
-
private void OnRenderSkia(SKSurface surface)
{
ViewModel?.RenderToSurface(surface, renderBackgroundFill: true, renderBackgroundImage: true);
diff --git a/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.axaml b/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.axaml
index bfbc310e2..ea08be458 100644
--- a/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.axaml
+++ b/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.axaml
@@ -1,30 +1,29 @@
-ο»Ώ
+ο»Ώ
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/Styles/ScrollBarStyles.axaml b/StabilityMatrix.Avalonia/Styles/ScrollBarStyles.axaml
new file mode 100644
index 000000000..b1c4629db
--- /dev/null
+++ b/StabilityMatrix.Avalonia/Styles/ScrollBarStyles.axaml
@@ -0,0 +1,65 @@
+ο»Ώ
+
+
+
+ False
+ 14
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.Downloads.cs b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.Downloads.cs
new file mode 100644
index 000000000..39f6c96ce
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.Downloads.cs
@@ -0,0 +1,283 @@
+using System.Threading;
+using AsyncAwaitBestPractices;
+using Avalonia.Controls.Notifications;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using FluentAvalonia.UI.Controls;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels.Dialogs;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Progress;
+using StabilityMatrix.Core.Services.ImageGeneration;
+
+namespace StabilityMatrix.Avalonia.ViewModels;
+
+public partial class BananaVisionPageViewModel
+{
+ ///
+ /// Whether there are missing models that can be downloaded
+ ///
+ [ObservableProperty]
+ public partial bool HasMissingModels { get; set; }
+
+ ///
+ /// Whether a model-download batch is currently in progress.
+ /// While true, the status banner shows download progress instead of the missing-models warning.
+ ///
+ [ObservableProperty]
+ public partial bool IsDownloadingModels { get; set; }
+
+ ///
+ /// Human-readable progress text for the in-flight download batch (e.g. "Downloading models (2/4)...").
+ ///
+ [ObservableProperty]
+ public partial string? DownloadProgressText { get; set; }
+
+ partial void OnIsDownloadingModelsChanged(bool value)
+ {
+ UpdateProviderStatus();
+ }
+
+ partial void OnDownloadProgressTextChanged(string? value)
+ {
+ if (IsDownloadingModels)
+ {
+ UpdateProviderStatus();
+ }
+ }
+
+ ///
+ /// Check for missing models and auto-show the download dialog if needed
+ ///
+ private async Task CheckAndShowMissingModelsDialogAsync()
+ {
+ // Don't show if we've already shown it this session
+ if (hasShownMissingModelsDialog)
+ return;
+
+ // Wait a moment for connection status to settle
+ await Task.Delay(500);
+
+ // Only show if connected and models are missing
+ if (!ClientManager.IsConnected || !HasMissingModels)
+ return;
+
+ hasShownMissingModelsDialog = true;
+ await ShowMissingModelsDialogAsync();
+ }
+
+ ///
+ /// Show the missing models download dialog
+ ///
+ [RelayCommand]
+ private async Task ShowMissingModelsDialogAsync()
+ {
+ if (!ClientManager.IsConnected)
+ {
+ notificationService.Show(
+ "Not Connected",
+ "Please connect to ComfyUI first to check for missing models.",
+ NotificationType.Warning
+ );
+ return;
+ }
+
+ // Get the model manager for the current provider
+ var modelManager = LocalProviderModelManagerRegistry.GetManager(SelectedProviderId);
+ if (modelManager == null)
+ {
+ logger.LogWarning("No model manager found for provider {ProviderId}", SelectedProviderId);
+ return;
+ }
+
+ var missingModels = modelManager.GetMissingModels(ClientManager).ToList();
+
+ if (missingModels.Count == 0)
+ {
+ notificationService.Show(
+ "All Models Present",
+ "All required models are already installed!",
+ NotificationType.Success
+ );
+ return;
+ }
+
+ logger.LogInformation(
+ "Showing missing models dialog for {Provider} with {Count} models",
+ modelManager.ProviderDisplayName,
+ missingModels.Count
+ );
+
+ // Create and configure the dialog using manager's properties
+ var dialogVm = vmFactory.Get();
+ dialogVm.DialogTitle = $"{modelManager.ProviderDisplayName} Setup";
+ dialogVm.Description = modelManager.DownloadDialogDescription;
+ dialogVm.SetModels(missingModels);
+
+ var dialog = dialogVm.GetDialog();
+ var result = await dialog.ShowAsync();
+
+ // If user clicked Download, start the downloads
+ if (result == ContentDialogResult.Primary && dialogVm.SelectedCount > 0)
+ {
+ // Start downloads (runs in background via TrackedDownloadService)
+ var downloads = await dialogVm.StartDownloadsAsync();
+
+ if (downloads.Count > 0)
+ {
+ // Switch the status banner over to a download-progress view so it doesn't
+ // keep showing "β οΈ Missing: X, Y, Z" with a Download button while the
+ // download is already running.
+ DownloadProgressText = $"β¬οΈ Downloading models (0/{downloads.Count})...";
+ IsDownloadingModels = true;
+
+ notificationService.Show(
+ "Downloads Started",
+ $"Downloading {downloads.Count} model(s). Check the progress panel for status.",
+ NotificationType.Information
+ );
+
+ // Track completion of all downloads
+ TrackDownloadCompletionAsync(downloads, modelManager.ProviderDisplayName)
+ .SafeFireAndForget(ex =>
+ {
+ logger.LogError(ex, "Failed to track download completion");
+ });
+ }
+ }
+ }
+
+ ///
+ /// Track when all downloads complete and show notification
+ ///
+ private async Task TrackDownloadCompletionAsync(
+ List downloads,
+ string providerDisplayName
+ )
+ {
+ var totalCount = downloads.Count;
+ var completedCount = 0;
+
+ void BumpProgress(ProgressState state)
+ {
+ // Each terminal-state event bumps the completed count; UI update is marshaled
+ // because ProgressStateChanged may fire from a background thread.
+ var newCompleted = Interlocked.Increment(ref completedCount);
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (IsDownloadingModels)
+ {
+ DownloadProgressText = $"β¬οΈ Downloading models ({newCompleted}/{totalCount})...";
+ }
+ });
+ }
+
+ var completionTasks = downloads
+ .Select(d =>
+ {
+ var tcs = new TaskCompletionSource();
+ var counted = 0; // Guard against double-counting if both handler + already-completed fire
+
+ void OnTerminal(ProgressState state)
+ {
+ if (Interlocked.Exchange(ref counted, 1) == 0)
+ {
+ BumpProgress(state);
+ }
+ tcs.TrySetResult(state == ProgressState.Success);
+ }
+
+ d.ProgressStateChanged += (s, state) =>
+ {
+ if (state is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled)
+ {
+ OnTerminal(state);
+ }
+ };
+
+ // Check if already completed
+ if (
+ d.ProgressState
+ is ProgressState.Success
+ or ProgressState.Failed
+ or ProgressState.Cancelled
+ )
+ {
+ OnTerminal(d.ProgressState);
+ }
+
+ return tcs.Task;
+ })
+ .ToList();
+
+ // Wait for all downloads to complete
+ var results = await Task.WhenAll(completionTasks);
+ var successCount = results.Count(r => r);
+ var failCount = results.Count(r => !r);
+
+ logger.LogInformation(
+ "Model downloads completed: {Success} succeeded, {Failed} failed",
+ successCount,
+ failCount
+ );
+
+ // Refresh model index
+ await modelIndexService.RefreshIndex();
+
+ // Reconnect to ComfyUI to refresh model lists
+ if (ClientManager.IsConnected)
+ {
+ try
+ {
+ await ClientManager.ConnectAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to reconnect after model download");
+ }
+ }
+
+ // Update status on UI thread
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ // Clear the download-in-progress flag before recomputing status so the banner
+ // returns to its normal state ("β
ready" or "β οΈ Missing: ...") immediately.
+ IsDownloadingModels = false;
+ DownloadProgressText = null;
+ UpdateProviderStatus();
+ LoadAvailableFluxModels();
+ LoadAvailableQwenModels();
+ LoadAvailableKleinModels();
+ });
+
+ // Show completion notification
+ if (failCount == 0 && successCount > 0)
+ {
+ notificationService.Show(
+ "Models Ready! π",
+ $"All required models have been downloaded. {providerDisplayName} is ready to use!",
+ NotificationType.Success,
+ TimeSpan.FromSeconds(8)
+ );
+ }
+ else if (successCount > 0)
+ {
+ notificationService.Show(
+ "Downloads Partially Complete",
+ $"{successCount} model(s) downloaded, {failCount} failed. Check the progress panel for details.",
+ NotificationType.Warning
+ );
+ }
+ else
+ {
+ notificationService.Show(
+ "Downloads Failed",
+ "All model downloads failed. Please check your connection and try again.",
+ NotificationType.Error
+ );
+ }
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.Models.cs b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.Models.cs
new file mode 100644
index 000000000..67bf7aafe
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.Models.cs
@@ -0,0 +1,417 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Notifications;
+using Avalonia.Layout;
+using Avalonia.Styling;
+using CommunityToolkit.Mvvm.Input;
+using FluentAvalonia.UI.Controls;
+using Microsoft.Extensions.Logging;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Models.BananaVision;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Services.ImageGeneration;
+
+namespace StabilityMatrix.Avalonia.ViewModels;
+
+public partial class BananaVisionPageViewModel
+{
+ ///
+ /// Sorts models by connected status first, then alphabetically by display name
+ ///
+ private static IOrderedEnumerable SortModelsByConnectedThenName(
+ IEnumerable models
+ )
+ {
+ return models
+ .OrderByDescending(m => m.Local?.ConnectedModelInfo != null)
+ .ThenBy(m => m.Local?.DisplayModelName ?? m.ShortDisplayName);
+ }
+
+ ///
+ /// Populates a collection with sorted models from multiple priority groups
+ ///
+ private static void PopulateModelCollection(
+ ObservableCollection collection,
+ params IEnumerable[] modelGroups
+ )
+ {
+ collection.Clear();
+ foreach (var group in modelGroups)
+ {
+ foreach (var model in SortModelsByConnectedThenName(group))
+ {
+ collection.Add(model);
+ }
+ }
+ }
+
+ ///
+ /// Categorizes models from a folder type based on search terms
+ ///
+ /// The folder type to search
+ /// Primary search terms (highest priority)
+ /// Secondary search terms (medium priority, optional)
+ /// Tuple of (matched models, secondary matched models, untagged models)
+ private (
+ List Primary,
+ List Secondary,
+ List Untagged
+ ) CategorizeModelsByTerms(
+ SharedFolderType folderType,
+ string[] primaryTerms,
+ string[]? secondaryTerms = null
+ )
+ {
+ var primaryModels = new List();
+ var secondaryModels = new List();
+ var untaggedModels = new List();
+
+ foreach (var model in modelIndexService.FindByModelType(folderType).Select(HybridModelFile.FromLocal))
+ {
+ var baseModel = model.Local?.ConnectedModelInfo?.BaseModel;
+
+ // Check primary terms first
+ if (
+ primaryTerms.Any(term =>
+ baseModel?.Contains(term, StringComparison.OrdinalIgnoreCase) == true
+ )
+ )
+ {
+ primaryModels.Add(model);
+ }
+ // Check secondary terms
+ else if (
+ secondaryTerms?.Any(term =>
+ baseModel?.Contains(term, StringComparison.OrdinalIgnoreCase) == true
+ ) == true
+ )
+ {
+ secondaryModels.Add(model);
+ }
+ // Check filename fallback for untagged models
+ else if (string.IsNullOrEmpty(baseModel))
+ {
+ if (
+ primaryTerms.Any(term =>
+ model.FileName.Contains(term, StringComparison.OrdinalIgnoreCase)
+ )
+ )
+ {
+ primaryModels.Add(model);
+ }
+ else
+ {
+ untaggedModels.Add(model);
+ }
+ }
+ }
+
+ return (primaryModels, secondaryModels, untaggedModels);
+ }
+
+ ///
+ /// Loads available Flux Kontext models from the DiffusionModels folder using local model index
+ ///
+ private void LoadAvailableFluxModels()
+ {
+ // Load UNet models - prioritize Kontext
+ var (kontextModels, _, untaggedModels) = CategorizeModelsByTerms(
+ SharedFolderType.DiffusionModels,
+ ["Kontext"]
+ );
+
+ PopulateModelCollection(AvailableFluxModels, kontextModels, untaggedModels);
+
+ // Auto-select first Kontext model if available
+ if (SelectedFluxModel == null && AvailableFluxModels.Count > 0)
+ {
+ SelectedFluxModel =
+ AvailableFluxModels.FirstOrDefault(m =>
+ m.FileName.Contains("kontext", StringComparison.OrdinalIgnoreCase)
+ ) ?? AvailableFluxModels.First();
+ }
+
+ // Load LoRA models - prioritize Kontext, then Flux, then untagged
+ var (kontextLoras, fluxLoras, untaggedLoras) = CategorizeModelsByTerms(
+ SharedFolderType.Lora | SharedFolderType.LyCORIS,
+ ["Kontext"],
+ ["Flux"]
+ );
+
+ PopulateModelCollection(AvailableFluxLoras, kontextLoras, fluxLoras, untaggedLoras);
+
+ logger.LogInformation(
+ "Loaded {ModelCount} Flux models and {LoraCount} LoRAs from local index",
+ AvailableFluxModels.Count,
+ AvailableFluxLoras.Count
+ );
+ }
+
+ ///
+ /// Loads available Qwen Image Edit models from the DiffusionModels folder using local model index
+ ///
+ private void LoadAvailableQwenModels()
+ {
+ // Load UNet models - prioritize Qwen
+ var (qwenModels, _, untaggedModels) = CategorizeModelsByTerms(
+ SharedFolderType.DiffusionModels,
+ ["Qwen"]
+ );
+
+ PopulateModelCollection(AvailableQwenModels, qwenModels, untaggedModels);
+
+ // Auto-select first Qwen model if available
+ if (SelectedQwenModel == null && AvailableQwenModels.Count > 0)
+ {
+ SelectedQwenModel =
+ AvailableQwenModels.FirstOrDefault(m =>
+ m.FileName.Contains("qwen", StringComparison.OrdinalIgnoreCase)
+ ) ?? AvailableQwenModels.First();
+ }
+
+ // Load LoRA models - prioritize Qwen, then untagged
+ var (qwenLoras, _, untaggedLoras) = CategorizeModelsByTerms(
+ SharedFolderType.Lora | SharedFolderType.LyCORIS,
+ ["Qwen"]
+ );
+
+ PopulateModelCollection(AvailableQwenLoras, qwenLoras, untaggedLoras);
+
+ logger.LogInformation(
+ "Loaded {ModelCount} Qwen models and {LoraCount} LoRAs from local index",
+ AvailableQwenModels.Count,
+ AvailableQwenLoras.Count
+ );
+ }
+
+ ///
+ /// Loads available Flux.2 Klein models from the DiffusionModels folder using local model index.
+ /// Picks up both Klein 4B and Klein 9B variants for the dropdown selector.
+ ///
+ private void LoadAvailableKleinModels()
+ {
+ // Load UNet models - prioritize Klein, then any Flux.2 (catches future variants), then untagged
+ var (kleinModels, flux2Models, untaggedModels) = CategorizeModelsByTerms(
+ SharedFolderType.DiffusionModels,
+ ["Klein", "flux-2-klein", "flux2-klein"],
+ ["Flux.2", "flux2"]
+ );
+
+ PopulateModelCollection(AvailableKleinModels, kleinModels, flux2Models, untaggedModels);
+
+ // Auto-select first Klein model if available β prefer 4B since it's the auto-downloaded
+ // Apache 2.0 default, then any other Klein variant the user has dropped in
+ if (SelectedKleinModel == null && AvailableKleinModels.Count > 0)
+ {
+ SelectedKleinModel =
+ AvailableKleinModels.FirstOrDefault(m =>
+ m.FileName.Contains("klein-4b", StringComparison.OrdinalIgnoreCase)
+ || m.FileName.Contains("klein_4b", StringComparison.OrdinalIgnoreCase)
+ )
+ ?? AvailableKleinModels.FirstOrDefault(m =>
+ m.FileName.Contains("klein", StringComparison.OrdinalIgnoreCase)
+ )
+ ?? AvailableKleinModels.First();
+ }
+
+ // Load LoRA models - prioritize Klein, then any Flux LoRA, then untagged
+ var (kleinLoras, fluxLoras, untaggedLoras) = CategorizeModelsByTerms(
+ SharedFolderType.Lora | SharedFolderType.LyCORIS,
+ ["Klein", "Flux.2"],
+ ["Flux"]
+ );
+
+ PopulateModelCollection(AvailableKleinLoras, kleinLoras, fluxLoras, untaggedLoras);
+
+ logger.LogInformation(
+ "Loaded {ModelCount} Klein models and {LoraCount} LoRAs from local index",
+ AvailableKleinModels.Count,
+ AvailableKleinLoras.Count
+ );
+ }
+
+ [RelayCommand]
+ private async Task AddLoraAsync()
+ {
+ // Get available LoRAs based on current provider
+ var availableLoras = SelectedProviderId switch
+ {
+ BananaVisionProviderIds.QwenImageEdit => AvailableQwenLoras,
+ BananaVisionProviderIds.Flux2Klein => AvailableKleinLoras,
+ _ => AvailableFluxLoras,
+ };
+
+ if (availableLoras.Count == 0)
+ {
+ notificationService.Show(
+ "No LoRAs Available",
+ "No compatible LoRA models found.",
+ NotificationType.Warning
+ );
+ return;
+ }
+
+ // Create a styled selection dialog using BetterComboBox with HybridModel theme
+ var comboBox = new BetterComboBox
+ {
+ ItemsSource = availableLoras,
+ SelectedIndex = 0,
+ MinWidth = 350,
+ Padding = new Thickness(8, 6, 4, 6),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ };
+
+ // Apply the HybridModel theme
+ if (
+ App.Current?.Resources.TryGetResource(
+ "BetterComboBoxHybridModelTheme",
+ App.Current.ActualThemeVariant,
+ out var theme
+ ) == true
+ && theme is ControlTheme controlTheme
+ )
+ {
+ comboBox.Theme = controlTheme;
+ }
+
+ var dialog = new ContentDialog
+ {
+ Title = "Add LoRA",
+ Content = comboBox,
+ PrimaryButtonText = "Add",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ };
+
+ var result = await dialog.ShowAsync();
+
+ if (result == ContentDialogResult.Primary && comboBox.SelectedItem is HybridModelFile selectedLora)
+ {
+ // Check if already added
+ if (SelectedLoras.Any(l => l.Model.RelativePath == selectedLora.RelativePath))
+ {
+ notificationService.Show(
+ "Already Added",
+ "This LoRA is already in the list.",
+ NotificationType.Warning
+ );
+ return;
+ }
+
+ SelectedLoras.Add(new SelectedLora { Model = selectedLora });
+ }
+ }
+
+ [RelayCommand]
+ private void RemoveLora(SelectedLora lora)
+ {
+ SelectedLoras.Remove(lora);
+ }
+
+ [RelayCommand]
+ private void ToggleFluxSettings()
+ {
+ IsFluxSettingsExpanded = !IsFluxSettingsExpanded;
+ }
+
+ [RelayCommand]
+ private void ToggleQwenSettings()
+ {
+ IsQwenSettingsExpanded = !IsQwenSettingsExpanded;
+ }
+
+ [RelayCommand]
+ private void ToggleKleinSettings()
+ {
+ IsKleinSettingsExpanded = !IsKleinSettingsExpanded;
+ }
+
+ ///
+ /// When the user picks a different Klein model, snap Steps/CFG to the recommended
+ /// defaults for that variant. Distilled = 4 steps / CFG 1, Base = 20 steps / CFG 5.
+ /// The user can still override afterwards; this just sets sane starting values.
+ ///
+ partial void OnSelectedKleinModelChanged(HybridModelFile? value)
+ {
+ if (value == null)
+ return;
+
+ var (recommendedSteps, recommendedCfg) = DetectKleinDefaults(value);
+ KleinSteps = recommendedSteps;
+ KleinCfg = recommendedCfg;
+ }
+
+ ///
+ /// Returns the recommended Steps and CFG for a Klein UNET, based on filename and
+ /// CivitAI metadata. Base variants need 20 steps / CFG 5; distilled needs 4 / 1.
+ /// 9B models without an explicit "distilled" tag are assumed to be base, since
+ /// Klein 9B distilled isn't publicly shipped β almost all 9B installs are base
+ /// (or fine-tunes of base). 4B without signals defaults to distilled, matching
+ /// our auto-downloaded Apache 2.0 default.
+ ///
+ private static (int Steps, double Cfg) DetectKleinDefaults(HybridModelFile model)
+ {
+ var info = model.Local?.ConnectedModelInfo;
+
+ var haystacks = new List { model.FileName };
+ if (info != null)
+ {
+ if (!string.IsNullOrEmpty(info.BaseModel))
+ haystacks.Add(info.BaseModel);
+ if (!string.IsNullOrEmpty(info.ModelName))
+ haystacks.Add(info.ModelName);
+ if (!string.IsNullOrEmpty(info.VersionName))
+ haystacks.Add(info.VersionName);
+ if (!string.IsNullOrEmpty(info.VersionDescription))
+ haystacks.Add(info.VersionDescription);
+ if (info.TrainedWords != null)
+ haystacks.AddRange(info.TrainedWords);
+ }
+
+ bool LooksLikeBase(string s) =>
+ s.Contains("base", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("non-distilled", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("non_distilled", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("nondistilled", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("foundation", StringComparison.OrdinalIgnoreCase);
+
+ bool LooksLikeDistilled(string s) =>
+ s.Contains("distilled", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("turbo", StringComparison.OrdinalIgnoreCase);
+
+ bool LooksLikeNineB(string s) =>
+ s.Contains("9b", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("9 b", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("9-b", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("klein 9", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("klein-9", StringComparison.OrdinalIgnoreCase)
+ || s.Contains("klein_9", StringComparison.OrdinalIgnoreCase);
+
+ var hasBaseSignal = haystacks.Any(LooksLikeBase);
+ var hasDistilledSignal = haystacks.Any(LooksLikeDistilled);
+ var hasNineBSignal = haystacks.Any(LooksLikeNineB);
+
+ // Ambiguous case: BOTH "base" and "distilled" appear (common for community uploads
+ // labeled e.g. "Klein 9B Base & Distilled" that cover both variants). Prefer base
+ // for 9B (distilled 9B isn't publicly shipped) and distilled for 4B (matches our
+ // auto-download default).
+ if (hasBaseSignal && hasDistilledSignal)
+ return hasNineBSignal ? (20, 5.0) : (4, 1.0);
+
+ // Unambiguous explicit tags.
+ if (hasDistilledSignal)
+ return (4, 1.0);
+ if (hasBaseSignal)
+ return (20, 5.0);
+
+ // No explicit base/distilled signal, but it's a 9B variant β default to base.
+ // Klein 9B distilled isn't publicly shipped, so 9B installs (including merges and
+ // fine-tunes) are almost always base-derived and need 20 steps / CFG 5.
+ if (hasNineBSignal)
+ return (20, 5.0);
+
+ // Default: distilled (matches the auto-downloaded Apache 2.0 Klein 4B).
+ return (4, 1.0);
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs
new file mode 100644
index 000000000..b78c4d6c1
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs
@@ -0,0 +1,2595 @@
+ο»Ώusing System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Reactive.Linq;
+using AsyncAwaitBestPractices;
+using Avalonia.Controls;
+using Avalonia.Controls.Notifications;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Media.Animation;
+using Injectio.Attributes;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Helpers;
+using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Models.BananaVision;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.Dialogs;
+using StabilityMatrix.Avalonia.ViewModels.Settings;
+using StabilityMatrix.Avalonia.Views;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Database;
+using StabilityMatrix.Core.Models.Packages;
+using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.ImageGeneration;
+
+namespace StabilityMatrix.Avalonia.ViewModels;
+
+[View(typeof(BananaVisionPage))]
+[RegisterSingleton]
+public partial class BananaVisionPageViewModel : PageViewModelBase
+{
+ private readonly ILogger logger;
+ private readonly IImageGenerationChatService chatService;
+ private readonly ISecretsManager secretsManager;
+ private readonly INotificationService notificationService;
+ private readonly IServiceManager vmFactory;
+ private readonly IModelIndexService modelIndexService;
+ private readonly INavigationService navigationService;
+ private readonly INavigationService settingsNavigationService;
+
+ public override string Title => "Image Lab";
+ public override IconSource IconSource => new FASymbolIconSource { Symbol = "fa-solid fa-flask" };
+
+ public IInferenceClientManager ClientManager { get; }
+
+ [ObservableProperty]
+ public partial string? NewMessageText { get; set; }
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
+ [NotifyPropertyChangedFor(nameof(IsCurrentConversationGenerating))]
+ public partial bool IsGenerating { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsCurrentConversationGenerating))]
+ public partial Guid? GeneratingConversationId { get; set; }
+
+ ///
+ /// True if the currently selected conversation is the one that's generating.
+ /// Used to scope the progress indicator to the active conversation.
+ ///
+ public bool IsCurrentConversationGenerating =>
+ IsGenerating && CurrentConversation != null && GeneratingConversationId == CurrentConversation.Id;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasGenerationProgress))]
+ [NotifyPropertyChangedFor(nameof(GenerationProgressText))]
+ public partial int? GenerationProgressPercent { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasGenerationProgress))]
+ [NotifyPropertyChangedFor(nameof(GenerationProgressText))]
+ public partial string? GenerationProgressStage { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasGenerationProgress))]
+ [NotifyPropertyChangedFor(nameof(GenerationProgressText))]
+ public partial string? GenerationProgressRunningNode { get; set; }
+
+ public bool HasGenerationProgress =>
+ RequiresLocalBackend
+ && (
+ GenerationProgressPercent != null
+ || !string.IsNullOrEmpty(GenerationProgressStage)
+ || !string.IsNullOrEmpty(GenerationProgressRunningNode)
+ );
+
+ public string GenerationProgressText
+ {
+ get
+ {
+ if (!IsGenerating)
+ return "Ready";
+
+ if (!RequiresLocalBackend)
+ return "Creating your image...";
+
+ var stage = string.IsNullOrWhiteSpace(GenerationProgressStage)
+ ? "Creating your image..."
+ : GenerationProgressStage;
+ var node = string.IsNullOrWhiteSpace(GenerationProgressRunningNode)
+ ? null
+ : GenerationProgressRunningNode.Replace('_', ' ');
+ var percent = GenerationProgressPercent;
+
+ if (percent is >= 0 and <= 100 && !string.IsNullOrWhiteSpace(node))
+ return $"{stage} ({percent}%) β’ {node}";
+ if (percent is >= 0 and <= 100)
+ return $"{stage} ({percent}%)";
+ if (!string.IsNullOrWhiteSpace(node))
+ return $"{stage} β’ {node}";
+
+ return stage;
+ }
+ }
+
+ [ObservableProperty]
+ public partial string? ErrorMessage { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsCurrentConversationGenerating))]
+ public partial ImageGenerationConversation? CurrentConversation { get; set; }
+
+ partial void OnCurrentConversationChanged(
+ ImageGenerationConversation? oldValue,
+ ImageGenerationConversation? newValue
+ )
+ {
+ // Cancel any pending message load from the previous conversation
+ loadMessagesCts?.Cancel();
+ loadMessagesCts?.Dispose();
+ loadMessagesCts = null;
+
+ if (newValue != null)
+ {
+ logger.LogInformation(
+ "Current conversation changed to: {ConversationId} - {Title} (provider: {ProviderId})",
+ newValue.Id,
+ newValue.Title,
+ newValue.ProviderId
+ );
+
+ // Auto-switch to the conversation's last-used provider for convenience.
+ // Users can still freely change it afterwards, and that change will be
+ // remembered when they send the next message.
+ if (newValue.ProviderId != SelectedProviderId)
+ {
+ SelectedProviderId = newValue.ProviderId;
+ }
+
+ // Create new cancellation token for this load operation
+ loadMessagesCts = new();
+ var token = loadMessagesCts.Token;
+
+ // Load messages for the new conversation (fire and forget with error handling)
+ LoadMessagesForConversationAsync(newValue, token)
+ .SafeFireAndForget(ex =>
+ {
+ logger.LogError(
+ ex,
+ "Unhandled error loading messages for conversation {Id}",
+ newValue.Id
+ );
+ });
+ }
+ else
+ {
+ logger.LogWarning("Current conversation set to null");
+ ClearMessages();
+ }
+ }
+
+ ///
+ /// Loads messages for a conversation without changing CurrentConversation
+ ///
+ private async Task LoadMessagesForConversationAsync(
+ ImageGenerationConversation conversation,
+ CancellationToken cancellationToken = default
+ )
+ {
+ // Clear on UI thread
+ await Dispatcher.UIThread.InvokeAsync(ClearMessages);
+
+ try
+ {
+ var messages = await chatService.GetMessagesAsync(conversation.Id);
+
+ // Check if cancelled before updating UI (user may have switched conversations)
+ cancellationToken.ThrowIfCancellationRequested();
+
+ logger.LogInformation(
+ "Loaded {Count} messages for conversation {Id}",
+ messages.Count,
+ conversation.Id
+ );
+
+ // Update UI on the UI thread
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ foreach (var message in messages)
+ {
+ AddMessageToUI(message);
+ }
+
+ // Notify gallery that images may have changed
+ OnPropertyChanged(nameof(ConversationImages));
+ OnPropertyChanged(nameof(HasConversationImages));
+
+ // If this conversation is currently generating, re-add the loading placeholder
+ if (GeneratingConversationId == conversation.Id && IsGenerating)
+ {
+ currentLoadingMessage = new LoadingImageMessage
+ {
+ TargetWidth = (SelectedAspectRatio?.Width ?? 300) / 3,
+ TargetHeight = (SelectedAspectRatio?.Height ?? 300) / 3,
+ };
+ Messages.Add(currentLoadingMessage);
+ }
+
+ // Start the view at the bottom when switching to a (potentially long) conversation.
+ // Guard against late completion after the user already switched away.
+ if (CurrentConversation?.Id == conversation.Id)
+ {
+ Dispatcher.UIThread.Post(
+ () => ScrollToEndForcedRequested?.Invoke(this, EventArgs.Empty),
+ DispatcherPriority.Background
+ );
+ }
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ // Conversation switch cancelled this load - this is expected, don't log as error
+ logger.LogDebug("Message loading cancelled for conversation {ConversationId}", conversation.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load messages for conversation {ConversationId}", conversation.Id);
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ErrorMessage = $"Failed to load messages: {ex.Message}";
+ });
+ }
+ }
+
+ [ObservableProperty]
+ public partial string? SelectedProviderId { get; set; }
+
+ [ObservableProperty]
+ public partial string? ProviderStatusMessage { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsFluxKontextAvailable { get; set; }
+
+ [ObservableProperty]
+ public partial bool CanRetryLastMessage { get; set; }
+
+ ///
+ /// Whether we can regenerate the last assistant response (true when there's at least one assistant message)
+ ///
+ public bool CanRegenerateLastResponse =>
+ Messages.OfType().Any(m => !m.IsMyMessage)
+ || Messages.OfType().Any(m => !m.IsMyMessage);
+
+ ///
+ /// Whether to show thinking/reasoning output from Gemini 3 Pro
+ ///
+ [ObservableProperty]
+ public partial bool ShowThinkingOutput { get; set; } = true;
+
+ ///
+ /// Whether the selected provider supports thinking output
+ ///
+ public bool SupportsThinking => BananaVisionProviderIds.SupportsThinking(SelectedProviderId);
+
+ ///
+ /// Whether the selected provider requires a local backend (ComfyUI)
+ ///
+ public bool RequiresLocalBackend => BananaVisionProviderIds.IsLocalProvider(SelectedProviderId);
+
+ ///
+ /// Whether the selected provider is a cloud/API provider (Gemini)
+ ///
+ public bool IsCloudProvider => BananaVisionProviderIds.IsCloudProvider(SelectedProviderId);
+
+ ///
+ /// Whether to show the Flux Kontext settings panel
+ ///
+ public bool ShowFluxSettings => SelectedProviderId == BananaVisionProviderIds.FluxKontext;
+
+ ///
+ /// Whether to show the Qwen Image Edit settings panel
+ ///
+ public bool ShowQwenSettings => SelectedProviderId == BananaVisionProviderIds.QwenImageEdit;
+
+ ///
+ /// Whether to show the Flux.2 Klein settings panel
+ ///
+ public bool ShowKleinSettings => SelectedProviderId == BananaVisionProviderIds.Flux2Klein;
+
+ ///
+ /// Whether the Flux settings panel is expanded
+ ///
+ [ObservableProperty]
+ public partial bool IsFluxSettingsExpanded { get; set; } = true;
+
+ ///
+ /// Whether the Qwen settings panel is expanded
+ ///
+ [ObservableProperty]
+ public partial bool IsQwenSettingsExpanded { get; set; } = true;
+
+ ///
+ /// Whether the Klein settings panel is expanded
+ ///
+ [ObservableProperty]
+ public partial bool IsKleinSettingsExpanded { get; set; } = true;
+
+ ///
+ /// Selected Flux Kontext model
+ ///
+ [ObservableProperty]
+ public partial HybridModelFile? SelectedFluxModel { get; set; }
+
+ ///
+ /// Selected Qwen Image Edit model
+ ///
+ [ObservableProperty]
+ public partial HybridModelFile? SelectedQwenModel { get; set; }
+
+ ///
+ /// Selected Flux.2 Klein model
+ ///
+ [ObservableProperty]
+ public partial HybridModelFile? SelectedKleinModel { get; set; }
+
+ ///
+ /// Sampling steps for Klein. Auto-set when the model changes (4 for distilled,
+ /// 20 for base); user can override via the Klein settings panel.
+ ///
+ [ObservableProperty]
+ public partial int KleinSteps { get; set; } = 4;
+
+ ///
+ /// CFG scale for Klein. Auto-set when the model changes (1 for distilled,
+ /// 5 for base); user can override via the Klein settings panel.
+ ///
+ [ObservableProperty]
+ public partial double KleinCfg { get; set; } = 1.0;
+
+ ///
+ /// Available Flux Kontext models (filtered by BaseModel metadata or untagged)
+ ///
+ public ObservableCollection AvailableFluxModels { get; } = [];
+
+ ///
+ /// Available Qwen Image Edit models (filtered by BaseModel metadata or filename)
+ ///
+ public ObservableCollection AvailableQwenModels { get; } = [];
+
+ ///
+ /// Available Flux.2 Klein models (filtered by BaseModel metadata or filename)
+ ///
+ public ObservableCollection AvailableKleinModels { get; } = [];
+
+ ///
+ /// Available LoRA models for Flux Kontext
+ ///
+ public ObservableCollection AvailableFluxLoras { get; } = [];
+
+ ///
+ /// Available LoRA models for Qwen Image Edit
+ ///
+ public ObservableCollection AvailableQwenLoras { get; } = [];
+
+ ///
+ /// Available LoRA models for Flux.2 Klein
+ ///
+ public ObservableCollection AvailableKleinLoras { get; } = [];
+
+ ///
+ /// Selected LoRAs with weights
+ ///
+ public ObservableCollection SelectedLoras { get; } = [];
+
+ ///
+ /// Available aspect ratio presets
+ ///
+ public ObservableCollection AvailableAspectRatios { get; } =
+ [
+ new("1:1", "Square", 1024, 1024),
+ new("16:9", "Landscape Wide", 1344, 768),
+ new("9:16", "Portrait Tall", 768, 1344),
+ new("4:3", "Landscape", 1152, 896),
+ new("3:4", "Portrait", 896, 1152),
+ new("3:2", "Photo Landscape", 1216, 832),
+ new("2:3", "Photo Portrait", 832, 1216),
+ new("21:9", "Ultrawide", 1536, 640),
+ new("9:21", "Ultra Tall", 640, 1536),
+ ];
+
+ ///
+ /// Selected aspect ratio
+ ///
+ [ObservableProperty]
+ public partial AspectRatioOption? SelectedAspectRatio { get; set; }
+
+ ///
+ /// Whether to use custom resolution instead of aspect ratio presets
+ ///
+ [ObservableProperty]
+ public partial bool UseCustomResolution { get; set; }
+
+ ///
+ /// Custom width when UseCustomResolution is true
+ ///
+ [ObservableProperty]
+ public partial int CustomWidth { get; set; } = 1024;
+
+ ///
+ /// Custom height when UseCustomResolution is true
+ ///
+ [ObservableProperty]
+ public partial int CustomHeight { get; set; } = 1024;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsComfyRunning))]
+ public partial PackagePair? RunningPackage { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsWaitingForConnection { get; set; }
+
+ ///
+ /// Indicates whether the user is dragging an image over the page
+ ///
+ [ObservableProperty]
+ public partial bool IsDragOverImage { get; set; }
+
+ ///
+ /// Whether the image gallery sidebar is visible
+ ///
+ [ObservableProperty]
+ public partial bool IsGalleryVisible { get; set; }
+
+ ///
+ /// Gets all images in the current conversation for the gallery view
+ ///
+ public IEnumerable ConversationImages =>
+ Messages.OfType().Where(m => !m.IsMyMessage);
+
+ ///
+ /// Whether there are any images in the conversation
+ ///
+ public bool HasConversationImages => ConversationImages.Any();
+
+ partial void OnIsWaitingForConnectionChanged(bool value)
+ {
+ UpdateProviderStatus();
+ }
+
+ public bool IsComfyRunning => RunningPackage?.BasePackage is ComfyUI;
+
+ private string? lastMessageText;
+ private List? lastMessageImagePaths;
+ private IDisposable? startupCompleteSubscription;
+ private bool hasShownMissingModelsDialog;
+ private CancellationTokenSource? loadMessagesCts;
+
+ ///
+ /// Tracks the current loading message so it can be reliably removed on cancellation.
+ ///
+ private LoadingImageMessage? currentLoadingMessage;
+
+ ///
+ /// Messages in the current conversation. Can contain MessageBase or ThinkingMessage.
+ ///
+ public ObservableCollection Messages { get; }
+
+ ///
+ /// Event raised when the message list should scroll to the end
+ ///
+ public event EventHandler? ScrollToEndRequested;
+
+ ///
+ /// Event raised when the message list should force-scroll to the end.
+ /// Used after switching conversations so users start at the bottom immediately.
+ ///
+ public event EventHandler? ScrollToEndForcedRequested;
+
+ public ObservableCollection Conversations { get; set; } = [];
+ public ObservableCollection AvailableProviders { get; set; } = [];
+
+ ///
+ /// Pending images to be sent with the next message
+ ///
+ public ObservableCollection PendingImages { get; set; } = [];
+
+ // Will be set by the view
+ public IStorageProvider? StorageProvider { get; set; }
+
+ public BananaVisionPageViewModel(
+ ILogger logger,
+ IImageGenerationChatService chatService,
+ ISecretsManager secretsManager,
+ INotificationService notificationService,
+ IInferenceClientManager inferenceClientManager,
+ RunningPackageService runningPackageService,
+ IServiceManager vmFactory,
+ IModelIndexService modelIndexService,
+ INavigationService navigationService,
+ INavigationService settingsNavigationService
+ )
+ {
+ this.logger = logger;
+ this.chatService = chatService;
+ this.secretsManager = secretsManager;
+ this.notificationService = notificationService;
+ this.vmFactory = vmFactory;
+ this.navigationService = navigationService;
+ this.settingsNavigationService = settingsNavigationService;
+ this.modelIndexService = modelIndexService;
+
+ ClientManager = inferenceClientManager;
+
+ // Initialize Messages collection and subscribe to changes for auto-scroll
+ Messages = [];
+ Messages.CollectionChanged += OnMessagesCollectionChanged;
+
+ // Load available providers
+ var providers = chatService.GetAvailableProviders();
+ foreach (var provider in providers)
+ {
+ AvailableProviders.Add(new(provider.ProviderId, provider.ProviderName));
+ }
+
+ // Set default provider (use the first provider's ID)
+ SelectedProviderId = AvailableProviders.FirstOrDefault()?.Id;
+
+ // Set default aspect ratio (1:1 Square)
+ SelectedAspectRatio = AvailableAspectRatios.FirstOrDefault();
+
+ // Subscribe to connection status changes
+ ClientManager.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName != nameof(IInferenceClientManager.IsConnected))
+ return;
+
+ UpdateProviderStatus();
+
+ // When connected and using a local provider, check for missing models
+ if (ClientManager.IsConnected && RequiresLocalBackend)
+ {
+ CheckAndShowMissingModelsDialogAsync()
+ .SafeFireAndForget(ex =>
+ {
+ logger.LogError(ex, "Failed to check for missing models");
+ });
+ }
+
+ // When disconnected during generation, cancel the pending operation
+ if (!ClientManager.IsConnected && IsGenerating && RequiresLocalBackend)
+ {
+ logger.LogWarning("ComfyUI disconnected during generation, cancelling...");
+ CancelGeneration();
+ }
+ };
+
+ // Subscribe to running package changes
+ runningPackageService.RunningPackages.CollectionChanged += (s, e) =>
+ {
+ // ComfyZluda inherits from ComfyUI, so this check covers both
+ var comfyPackage = runningPackageService
+ .RunningPackages.FirstOrDefault(p => p.Value.RunningPackage.BasePackage is ComfyUI)
+ .Value?.RunningPackage;
+
+ // Handle package startup - auto-connect when ComfyUI starts
+ if (comfyPackage != null && RunningPackage == null)
+ {
+ RunningPackage = comfyPackage;
+
+ // Dispose previous subscription if any
+ startupCompleteSubscription?.Dispose();
+
+ // Subscribe to StartupComplete event for auto-connect
+ IsWaitingForConnection = true;
+ startupCompleteSubscription = Observable
+ .FromEventPattern(
+ comfyPackage.BasePackage,
+ nameof(comfyPackage.BasePackage.StartupComplete)
+ )
+ .Take(1)
+ .Subscribe(_ =>
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ // Only auto-connect for local providers (Flux Kontext, Qwen Image Edit, etc.)
+ if (RequiresLocalBackend && ClientManager.CanUserConnect)
+ {
+ logger.LogInformation(
+ "ComfyUI startup complete, auto-connecting for local provider..."
+ );
+ await ConnectAsync();
+ }
+
+ IsWaitingForConnection = false;
+ });
+ });
+ }
+ else if (comfyPackage == null && RunningPackage != null)
+ {
+ // Package stopped
+ startupCompleteSubscription?.Dispose();
+ startupCompleteSubscription = null;
+ IsWaitingForConnection = false;
+ }
+
+ RunningPackage = comfyPackage;
+ UpdateProviderStatus();
+ };
+
+ // Initial status update
+ var initialComfyPackage = runningPackageService
+ .RunningPackages.FirstOrDefault(p => p.Value.RunningPackage.BasePackage is ComfyUI)
+ .Value?.RunningPackage;
+
+ RunningPackage = initialComfyPackage;
+
+ // If ComfyUI is already running and we're using a local provider, try to connect
+ if (initialComfyPackage != null && RequiresLocalBackend && !ClientManager.IsConnected)
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ await Task.Delay(500); // Small delay to ensure ComfyUI is ready
+ if (ClientManager.CanUserConnect)
+ {
+ logger.LogInformation("ComfyUI already running on load, attempting connection...");
+ await ConnectAsync();
+ }
+ });
+ }
+
+ UpdateProviderStatus();
+ }
+
+ private void ResetGenerationProgress()
+ {
+ GenerationProgressPercent = null;
+ GenerationProgressStage = null;
+ GenerationProgressRunningNode = null;
+ OnPropertyChanged(nameof(GenerationProgressText));
+ }
+
+ private IProgress CreateProgressReporter(string providerId)
+ {
+ return new Progress(progress =>
+ {
+ // Only show progress for the active local generation session/provider.
+ if (!RequiresLocalBackend || SelectedProviderId != providerId)
+ return;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ GenerationProgressPercent = progress.Percent;
+ GenerationProgressStage = progress.Stage;
+ GenerationProgressRunningNode = progress.RunningNode;
+ OnPropertyChanged(nameof(GenerationProgressText));
+ });
+ });
+ }
+
+ public override async Task OnLoadedAsync()
+ {
+ await base.OnLoadedAsync();
+
+ logger.LogInformation("BananaVisionPage loaded, initializing...");
+
+ // Load conversations
+ logger.LogInformation("Loading conversations from database...");
+ await LoadConversationsAsync();
+ logger.LogInformation("Loaded {Count} conversations", Conversations.Count);
+
+ // Create or load a conversation
+ if (Conversations.Count == 0 && SelectedProviderId != null)
+ {
+ logger.LogInformation("No conversations found, creating new conversation");
+ await NewConversationAsync();
+ }
+ else if (Conversations.Count > 0)
+ {
+ logger.LogInformation("Loading most recent conversation: {ConversationId}", Conversations[0].Id);
+ await LoadConversationAsync(Conversations[0]);
+ }
+ }
+
+ private async Task LoadConversationsAsync()
+ {
+ try
+ {
+ var conversations = await chatService.GetConversationsAsync();
+
+ // Update UI on the UI thread
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Conversations.Clear();
+ foreach (var conversation in conversations)
+ {
+ Conversations.Add(conversation);
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load conversations");
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ErrorMessage = $"Failed to load conversations: {ex.Message}";
+ });
+ }
+ }
+
+ [RelayCommand]
+ private async Task NewConversationAsync()
+ {
+ if (string.IsNullOrEmpty(SelectedProviderId))
+ {
+ notificationService.Show("Error", "Please select a provider", NotificationType.Error);
+ return;
+ }
+
+ try
+ {
+ var conversation = await chatService.CreateConversationAsync(SelectedProviderId);
+ Conversations.Insert(0, conversation);
+ await LoadConversationAsync(conversation);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create conversation");
+ notificationService.Show(
+ "Error",
+ $"Failed to create conversation: {ex.Message}",
+ NotificationType.Error
+ );
+ }
+ }
+
+ [RelayCommand]
+ private Task LoadConversationAsync(ImageGenerationConversation conversation)
+ {
+ // Setting CurrentConversation triggers OnCurrentConversationChanged which loads the messages
+ CurrentConversation = conversation;
+ return Task.CompletedTask;
+ }
+
+ [RelayCommand]
+ private async Task DeleteConversationAsync(ImageGenerationConversation conversation)
+ {
+ // Show confirmation dialog
+ var dialog = new ContentDialog
+ {
+ Title = "Delete Conversation",
+ Content =
+ $"Are you sure you want to delete \"{conversation.Title}\"?\n\nThis will also delete all messages and generated images in this conversation.",
+ PrimaryButtonText = "Delete",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ };
+
+ var result = await dialog.ShowAsync();
+ if (result != ContentDialogResult.Primary)
+ {
+ return;
+ }
+
+ try
+ {
+ await chatService.DeleteConversationAsync(conversation.Id);
+ Conversations.Remove(conversation);
+
+ if (CurrentConversation?.Id == conversation.Id)
+ {
+ ClearMessages();
+ CurrentConversation = null;
+
+ // Load first conversation if available
+ if (Conversations.Count > 0)
+ {
+ await LoadConversationAsync(Conversations[0]);
+ }
+ }
+
+ notificationService.Show("Success", "Conversation deleted", NotificationType.Success);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete conversation {ConversationId}", conversation.Id);
+ notificationService.Show(
+ "Error",
+ $"Failed to delete conversation: {ex.Message}",
+ NotificationType.Error
+ );
+ }
+ }
+
+ [RelayCommand]
+ private async Task RenameConversationAsync(ImageGenerationConversation conversation)
+ {
+ try
+ {
+ var textBox = new TextBox
+ {
+ Text = conversation.Title,
+ Watermark = "Enter conversation name...",
+ MinWidth = 300,
+ };
+
+ var dialog = new ContentDialog
+ {
+ Title = "Rename Conversation",
+ Content = textBox,
+ PrimaryButtonText = "Save",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ };
+
+ var result = await dialog.ShowAsync();
+
+ if (result == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(textBox.Text))
+ {
+ conversation.Title = textBox.Text.Trim();
+ await chatService.UpdateConversationAsync(conversation);
+
+ // Refresh the list to update UI
+ var index = Conversations.IndexOf(conversation);
+ if (index >= 0)
+ {
+ Conversations[index] = conversation;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to rename conversation {ConversationId}", conversation.Id);
+ notificationService.Show(
+ "Error",
+ $"Failed to rename conversation: {ex.Message}",
+ NotificationType.Error
+ );
+ }
+ }
+
+ ///
+ /// Gets the display name for a provider ID
+ ///
+ public string GetProviderDisplayName(string? providerId)
+ {
+ if (string.IsNullOrEmpty(providerId))
+ return "Unknown";
+ return AvailableProviders.FirstOrDefault(p => p.Id == providerId)?.DisplayName ?? providerId;
+ }
+
+ [RelayCommand]
+ private async Task ConnectAsync()
+ {
+ const int maxRetries = 5;
+ const int retryDelayMs = 1000;
+
+ // Hold IsWaitingForConnection for the whole retry loop so the status banner and
+ // Connect button don't flicker as ClientManager.IsConnecting toggles per attempt
+ // (each failed attempt + retry delay would otherwise expose CanUserConnect briefly).
+ IsWaitingForConnection = true;
+ try
+ {
+ for (var attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ logger.LogInformation(
+ "Attempting to connect to ComfyUI (attempt {Attempt}/{MaxRetries})...",
+ attempt,
+ maxRetries
+ );
+ await ClientManager.ConnectAsync();
+ notificationService.Show(
+ "Connected",
+ "Successfully connected to ComfyUI",
+ NotificationType.Success
+ );
+ return; // Success - exit the method
+ }
+ catch (HttpRequestException ex)
+ when (ex.InnerException
+ is SocketException { SocketErrorCode: SocketError.ConnectionRefused }
+ )
+ {
+ // Connection refused - ComfyUI might still be starting up
+ if (attempt < maxRetries)
+ {
+ logger.LogDebug(
+ "Connection refused (attempt {Attempt}/{MaxRetries}), retrying in {Delay}ms...",
+ attempt,
+ maxRetries,
+ retryDelayMs
+ );
+ await Task.Delay(retryDelayMs);
+ }
+ else
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to connect to ComfyUI after {MaxRetries} attempts",
+ maxRetries
+ );
+ notificationService.Show(
+ "Connection Failed",
+ "Could not connect to ComfyUI. Make sure it's running and try again.",
+ NotificationType.Warning
+ );
+ }
+ }
+ catch (Exception ex)
+ {
+ // Other errors - don't retry
+ logger.LogError(ex, "Failed to connect to ComfyUI");
+ notificationService.Show("Connection Failed", ex.Message, NotificationType.Error);
+ return;
+ }
+ }
+ }
+ finally
+ {
+ IsWaitingForConnection = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ShowConnectionHelpAsync()
+ {
+ var viewModel = App.Services.GetRequiredService();
+ var dialog = viewModel.CreateDialog();
+
+ await dialog.ShowAsync();
+
+ // After dialog closes, check if we should connect
+ if (IsComfyRunning && ClientManager.CanUserConnect)
+ {
+ await ConnectAsync();
+ }
+ }
+
+ [RelayCommand(IncludeCancelCommand = true)]
+ private async Task SendMessageAsync(CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(NewMessageText) && PendingImages.Count == 0)
+ return;
+
+ if (CurrentConversation == null)
+ {
+ notificationService.Show("Error", "No conversation selected", NotificationType.Error);
+ return;
+ }
+
+ if (string.IsNullOrEmpty(SelectedProviderId))
+ {
+ notificationService.Show("Error", "Please select a provider", NotificationType.Error);
+ return;
+ }
+
+ var messageText = NewMessageText;
+ var imagePaths = PendingImages.Select(p => p.FilePath).ToList();
+
+ // Store for retry
+ lastMessageText = messageText;
+ lastMessageImagePaths = imagePaths.Count > 0 ? imagePaths : null;
+
+ NewMessageText = string.Empty;
+ ErrorMessage = null;
+ CanRetryLastMessage = false;
+
+ // Add user message to UI immediately
+ var provisionalUiItems = new List();
+ if (!string.IsNullOrWhiteSpace(messageText))
+ {
+ var uiText = new TextMessage(messageText, true);
+ provisionalUiItems.Add(uiText);
+ Messages.Add(uiText);
+ }
+
+ // Show pending images in chat (provisional; will be replaced by persisted copies after DB save)
+ foreach (var pendingImage in PendingImages)
+ {
+ var uiImage = new ImageMessage(pendingImage.Bitmap, true);
+ provisionalUiItems.Add(uiImage);
+ Messages.Add(uiImage);
+ }
+
+ // Clear pending images
+ PendingImages.Clear();
+
+ IsGenerating = true;
+ ResetGenerationProgress();
+ if (RequiresLocalBackend && !string.IsNullOrEmpty(SelectedProviderId))
+ {
+ GenerationProgressStage = "Starting...";
+ }
+
+ // Track which conversation is generating (for restoring placeholder on switch back)
+ GeneratingConversationId = CurrentConversation.Id;
+
+ // Add loading placeholder (scaled to 1/3 of target size for compact display)
+ currentLoadingMessage = new LoadingImageMessage
+ {
+ TargetWidth = (SelectedAspectRatio?.Width ?? 300) / 3,
+ TargetHeight = (SelectedAspectRatio?.Height ?? 300) / 3,
+ };
+ Messages.Add(currentLoadingMessage);
+
+ try
+ {
+ // Build provider options
+ var providerOptions = BuildProviderOptions();
+ var progress =
+ RequiresLocalBackend && SelectedProviderId != null
+ ? CreateProgressReporter(SelectedProviderId)
+ : null;
+
+ var (userMessage, assistantMessage) = await chatService.SendMessageAsync(
+ CurrentConversation.Id,
+ SelectedProviderId!,
+ messageText,
+ imagePaths.Count > 0 ? imagePaths : null,
+ providerOptions,
+ progress,
+ cancellationToken
+ );
+
+ // Remove loading placeholder
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+
+ // Replace provisional user UI items with canonical DB-backed messages (with IDs and persisted image paths).
+ foreach (var item in provisionalUiItems)
+ {
+ Messages.Remove(item);
+ if (item is ImageMessage imageMessage)
+ {
+ imageMessage.Image?.Dispose();
+ }
+ }
+
+ AddUserMessageToUI(userMessage);
+
+ // Add assistant response to UI
+ if (assistantMessage != null)
+ {
+ AddAssistantMessageToUI(assistantMessage);
+ }
+
+ // Reload conversations to update timestamps and titles
+ await LoadConversationsAsync();
+
+ // Update current conversation reference to reflect title changes
+ if (CurrentConversation != null)
+ {
+ var updatedConversation = Conversations.FirstOrDefault(c => c.Id == CurrentConversation.Id);
+ if (updatedConversation != null)
+ {
+ CurrentConversation = updatedConversation;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Check if cancellation was due to connection loss
+ if (RequiresLocalBackend && !ClientManager.IsConnected)
+ {
+ logger.LogWarning("Message generation cancelled due to connection loss");
+ ErrorMessage = "Connection to ComfyUI was lost during generation.";
+ notificationService.Show(
+ "Connection Lost",
+ "ComfyUI disconnected during generation",
+ NotificationType.Warning
+ );
+ }
+ else
+ {
+ logger.LogInformation("Message generation cancelled");
+ ErrorMessage = "Cancelled";
+ }
+ CanRetryLastMessage = true;
+ }
+ catch (ImageGenerationException ex)
+ {
+ // Expected error from generation (provider error, API error, etc.)
+ logger.LogWarning("Image generation failed: {Message}", ex.Message);
+
+ // Check if this is an API key error
+ if (ex.Message.Contains("API key", StringComparison.OrdinalIgnoreCase))
+ {
+ await ShowApiKeyRequiredDialogAsync();
+ CanRetryLastMessage = true;
+ }
+ else
+ {
+ ErrorMessage = ex.Message;
+ notificationService.Show("Generation Failed", ex.Message, NotificationType.Warning);
+ CanRetryLastMessage = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Unexpected error
+ logger.LogError(ex, "Unexpected error sending message");
+ ErrorMessage = $"Unexpected error: {ex.Message}";
+ notificationService.Show("Error", ex.Message, NotificationType.Error);
+ CanRetryLastMessage = true;
+ }
+ finally
+ {
+ IsGenerating = false;
+ GeneratingConversationId = null;
+ ResetGenerationProgress();
+ // Ensure loading placeholder is removed on cancel/error
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+ }
+ }
+
+ ///
+ /// Shows a dialog prompting the user to add their Gemini API key in settings
+ ///
+ private async Task ShowApiKeyRequiredDialogAsync()
+ {
+ var dialog = new ContentDialog
+ {
+ Title = "API Key Required",
+ Content =
+ "Gemini API key not configured. Please add your Gemini API key in Account Settings to use cloud providers.",
+ PrimaryButtonText = "Open Settings",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ };
+
+ var result = await dialog.ShowAsync();
+
+ if (result == ContentDialogResult.Primary)
+ {
+ // Navigate to Settings -> Account Settings
+ navigationService.NavigateTo(new SuppressNavigationTransitionInfo());
+ await Task.Delay(100);
+ settingsNavigationService.NavigateTo(
+ new SuppressNavigationTransitionInfo()
+ );
+ }
+ }
+
+ [RelayCommand(IncludeCancelCommand = true)]
+ private async Task RetryLastMessageAsync(CancellationToken cancellationToken)
+ {
+ if (CurrentConversation == null)
+ return;
+
+ if (string.IsNullOrEmpty(SelectedProviderId))
+ {
+ notificationService.Show("Error", "Please select a provider", NotificationType.Error);
+ return;
+ }
+
+ // Clear error state
+ ErrorMessage = null;
+ CanRetryLastMessage = false;
+ IsGenerating = true;
+ ResetGenerationProgress();
+ if (RequiresLocalBackend && !string.IsNullOrEmpty(SelectedProviderId))
+ {
+ GenerationProgressStage = "Starting...";
+ }
+
+ // Track which conversation is generating (for restoring placeholder on switch back)
+ GeneratingConversationId = CurrentConversation.Id;
+
+ try
+ {
+ // Build provider options
+ var providerOptions = BuildProviderOptions();
+ var progress =
+ RequiresLocalBackend && SelectedProviderId != null
+ ? CreateProgressReporter(SelectedProviderId)
+ : null;
+
+ // Add loading placeholder (scaled to 1/3 of target size for compact display)
+ currentLoadingMessage = new LoadingImageMessage
+ {
+ TargetWidth = (SelectedAspectRatio?.Width ?? 300) / 3,
+ TargetHeight = (SelectedAspectRatio?.Height ?? 300) / 3,
+ };
+ Messages.Add(currentLoadingMessage);
+
+ // Retry generation - this doesn't create a new user message
+ var assistantMessage = await chatService.RetryGenerationAsync(
+ CurrentConversation.Id,
+ SelectedProviderId,
+ providerOptions,
+ progress,
+ cancellationToken
+ );
+
+ // Remove loading placeholder
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+
+ // Add only the assistant response to UI
+ AddAssistantMessageToUI(assistantMessage, includeDbId: false);
+
+ // Reload conversations to update timestamps
+ await LoadConversationsAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ // Check if cancellation was due to connection loss
+ if (RequiresLocalBackend && !ClientManager.IsConnected)
+ {
+ logger.LogWarning("Retry generation cancelled due to connection loss");
+ ErrorMessage = "Connection to ComfyUI was lost during generation.";
+ notificationService.Show(
+ "Connection Lost",
+ "ComfyUI disconnected during generation",
+ NotificationType.Warning
+ );
+ }
+ else
+ {
+ logger.LogInformation("Retry generation cancelled");
+ ErrorMessage = "Cancelled";
+ }
+ CanRetryLastMessage = true;
+ }
+ catch (ImageGenerationException ex)
+ {
+ logger.LogWarning("Retry generation failed: {Message}", ex.Message);
+
+ // Check if this is an API key error
+ if (ex.Message.Contains("API key", StringComparison.OrdinalIgnoreCase))
+ {
+ await ShowApiKeyRequiredDialogAsync();
+ CanRetryLastMessage = true;
+ }
+ else
+ {
+ ErrorMessage = ex.Message;
+ notificationService.Show("Generation Failed", ex.Message, NotificationType.Warning);
+ CanRetryLastMessage = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error during retry");
+ ErrorMessage = $"Unexpected error: {ex.Message}";
+ notificationService.Show("Error", ex.Message, NotificationType.Error);
+ CanRetryLastMessage = true;
+ }
+ finally
+ {
+ IsGenerating = false;
+ GeneratingConversationId = null;
+ ResetGenerationProgress();
+ // Ensure loading placeholder is removed on cancel/error
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+ }
+ }
+
+ [RelayCommand]
+ private void DismissError()
+ {
+ ErrorMessage = null;
+ CanRetryLastMessage = false;
+ }
+
+ ///
+ /// Regenerates the last assistant response (without an error context)
+ ///
+ [RelayCommand(IncludeCancelCommand = true)]
+ private async Task RegenerateLastResponseAsync(CancellationToken cancellationToken)
+ {
+ if (CurrentConversation == null)
+ return;
+
+ if (string.IsNullOrEmpty(SelectedProviderId))
+ {
+ notificationService.Show("Error", "Please select a provider", NotificationType.Error);
+ return;
+ }
+
+ // Clear error state
+ ErrorMessage = null;
+ CanRetryLastMessage = false;
+ IsGenerating = true;
+ ResetGenerationProgress();
+ if (RequiresLocalBackend && !string.IsNullOrEmpty(SelectedProviderId))
+ {
+ GenerationProgressStage = "Starting...";
+ }
+
+ // Track which conversation is generating (for restoring placeholder on switch back)
+ GeneratingConversationId = CurrentConversation.Id;
+
+ try
+ {
+ // Remove the last assistant message(s) from UI before regenerating
+ var messagesToRemove = new List();
+ for (var i = Messages.Count - 1; i >= 0; i--)
+ {
+ var message = Messages[i];
+ // Stop when we hit a user message
+ if (message is TextMessage tm && tm.IsMyMessage)
+ break;
+ if (message is ImageMessage im && im.IsMyMessage)
+ break;
+
+ messagesToRemove.Add(message);
+ }
+
+ // Remove in reverse order to avoid index issues
+ foreach (var message in messagesToRemove)
+ {
+ Messages.Remove(message);
+ // Dispose image if needed
+ if (message is ImageMessage imageMessage)
+ {
+ imageMessage.Image?.Dispose();
+ }
+ }
+
+ // Delete old assistant messages from database (but preserve their image files)
+ var dbMessages = await chatService.GetMessagesAsync(CurrentConversation.Id);
+ var lastUserMessage = dbMessages.LastOrDefault(m => m.Role == MessageRole.User);
+ if (lastUserMessage != null)
+ {
+ // Find all assistant messages after the last user message
+ var oldAssistantMessages = dbMessages
+ .Where(m => m.Role == MessageRole.Assistant && m.Timestamp > lastUserMessage.Timestamp)
+ .ToList();
+
+ // Delete them from database but preserve image files for the output browser
+ foreach (var oldMsg in oldAssistantMessages)
+ {
+ await chatService.DeleteMessageAsync(oldMsg.Id, preserveImageFile: true);
+ }
+
+ if (oldAssistantMessages.Count > 0)
+ {
+ logger.LogInformation(
+ "Removed {Count} old assistant message(s) from database, preserved image files",
+ oldAssistantMessages.Count
+ );
+ }
+ }
+
+ // Build provider options
+ var providerOptions = BuildProviderOptions();
+ var progress =
+ RequiresLocalBackend && SelectedProviderId != null
+ ? CreateProgressReporter(SelectedProviderId)
+ : null;
+
+ // Add loading placeholder (scaled to 1/3 of target size for compact display)
+ currentLoadingMessage = new LoadingImageMessage
+ {
+ TargetWidth = (SelectedAspectRatio?.Width ?? 300) / 3,
+ TargetHeight = (SelectedAspectRatio?.Height ?? 300) / 3,
+ };
+ Messages.Add(currentLoadingMessage);
+
+ // Retry generation - this doesn't create a new user message
+ var assistantMessage = await chatService.RetryGenerationAsync(
+ CurrentConversation.Id,
+ SelectedProviderId,
+ providerOptions,
+ progress,
+ cancellationToken
+ );
+
+ // Remove loading placeholder
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+
+ // Add the new assistant response to UI
+ var addedAnyImages = AddAssistantMessageToUI(assistantMessage, includeDbId: false);
+
+ if (addedAnyImages)
+ {
+ // Notify gallery
+ OnPropertyChanged(nameof(ConversationImages));
+ OnPropertyChanged(nameof(HasConversationImages));
+ }
+
+ // Reload conversations to update timestamps
+ await LoadConversationsAsync();
+
+ // Notify property change
+ OnPropertyChanged(nameof(CanRegenerateLastResponse));
+ }
+ catch (OperationCanceledException)
+ {
+ // Check if cancellation was due to connection loss
+ if (RequiresLocalBackend && !ClientManager.IsConnected)
+ {
+ logger.LogWarning("Regenerate cancelled due to connection loss");
+ ErrorMessage = "Connection to ComfyUI was lost during generation.";
+ notificationService.Show(
+ "Connection Lost",
+ "ComfyUI disconnected during generation",
+ NotificationType.Warning
+ );
+ }
+ else
+ {
+ logger.LogInformation("Regenerate cancelled");
+ ErrorMessage = "Cancelled";
+ }
+ CanRetryLastMessage = true;
+ }
+ catch (ImageGenerationException ex)
+ {
+ logger.LogWarning("Regenerate failed: {Message}", ex.Message);
+
+ // Check if this is an API key error
+ if (ex.Message.Contains("API key", StringComparison.OrdinalIgnoreCase))
+ {
+ await ShowApiKeyRequiredDialogAsync();
+ CanRetryLastMessage = true;
+ }
+ else
+ {
+ ErrorMessage = ex.Message;
+ notificationService.Show("Generation Failed", ex.Message, NotificationType.Warning);
+ CanRetryLastMessage = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error during regenerate");
+ ErrorMessage = $"Unexpected error: {ex.Message}";
+ notificationService.Show("Error", ex.Message, NotificationType.Error);
+ CanRetryLastMessage = true;
+ }
+ finally
+ {
+ IsGenerating = false;
+ GeneratingConversationId = null;
+ ResetGenerationProgress();
+ // Ensure loading placeholder is removed on cancel/error
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+ }
+ }
+
+ ///
+ /// Edits a user message with option to save only or save and regenerate
+ ///
+ [RelayCommand]
+ private async Task EditUserMessageAsync(TextMessage? message)
+ {
+ if (message == null || !message.IsMyMessage || CurrentConversation == null)
+ return;
+
+ try
+ {
+ var existingMessageId = message.DatabaseMessageId;
+
+ // Show edit dialog with two action options
+ var textBox = new TextBox
+ {
+ Text = message.Text,
+ Watermark = "Edit your message...",
+ MinWidth = 400,
+ MinHeight = 100,
+ AcceptsReturn = true,
+ TextWrapping = global::Avalonia.Media.TextWrapping.Wrap,
+ };
+
+ var dialog = new ContentDialog
+ {
+ Title = "Edit Message",
+ Content = textBox,
+ PrimaryButtonText = "Save & Regenerate",
+ SecondaryButtonText = "Save Only",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ };
+
+ var result = await dialog.ShowAsync();
+
+ if (result == ContentDialogResult.None || string.IsNullOrWhiteSpace(textBox.Text))
+ return;
+
+ var editedText = textBox.Text.Trim();
+ var shouldRegenerate = result == ContentDialogResult.Primary;
+
+ // Get all messages from database
+ var dbMessages = await chatService.GetMessagesAsync(CurrentConversation.Id);
+ var dbMessage =
+ existingMessageId != null
+ ? dbMessages.FirstOrDefault(m => m.Id == existingMessageId.Value)
+ : null;
+
+ if (dbMessage == null)
+ {
+ // Message doesn't have a DatabaseMessageId - this is legacy data from before we tracked IDs.
+ // We cannot safely edit these messages because mapping UI messages to database entries
+ // is unreliable (a single database message can contain both text and images, but they
+ // appear as separate UI elements). Refuse to edit to prevent data corruption.
+ logger.LogWarning(
+ "Cannot edit message without DatabaseMessageId - legacy message from before ID tracking"
+ );
+ notificationService.Show(
+ "Cannot Edit",
+ "This message cannot be edited because it was created before message tracking was added. "
+ + "You can still send new messages normally.",
+ NotificationType.Warning
+ );
+ return;
+ }
+
+ if (shouldRegenerate)
+ {
+ // Original behavior: delete from this point and regenerate
+ await EditAndRegenerateAsync(message, dbMessage, dbMessages, editedText);
+ }
+ else
+ {
+ // New behavior: just update the text without regenerating
+ await EditMessageOnlyAsync(message, dbMessage, editedText);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to edit message");
+ notificationService.Show(
+ "Error",
+ $"Failed to edit message: {ex.Message}",
+ NotificationType.Error
+ );
+ }
+ }
+
+ ///
+ /// Updates a message's text without regenerating subsequent messages
+ ///
+ private async Task EditMessageOnlyAsync(
+ TextMessage uiMessage,
+ ImageGenerationMessage dbMessage,
+ string newText
+ )
+ {
+ // Update in database
+ var updatedMessage = await chatService.UpdateMessageTextAsync(dbMessage.Id, newText);
+ if (updatedMessage == null)
+ {
+ notificationService.Show("Error", "Failed to update message", NotificationType.Error);
+ return;
+ }
+
+ // Replace the UI message (TextMessage.Text is read-only)
+ var index = Messages.IndexOf(uiMessage);
+ if (index >= 0)
+ {
+ Messages[index] = new TextMessage(newText, uiMessage.IsMyMessage)
+ {
+ DatabaseMessageId = dbMessage.Id,
+ };
+ }
+
+ logger.LogInformation("Updated message {MessageId} text without regeneration", dbMessage.Id);
+ notificationService.Show("Message Updated", "Your message has been saved.", NotificationType.Success);
+ }
+
+ ///
+ /// Edits a message and regenerates the conversation from that point
+ ///
+ private async Task EditAndRegenerateAsync(
+ TextMessage uiMessage,
+ ImageGenerationMessage dbMessage,
+ List allDbMessages,
+ string editedText
+ )
+ {
+ // Delete all UI messages from this point onward
+ var firstUiIndexToDelete = -1;
+ for (var i = 0; i < Messages.Count; i++)
+ {
+ if (GetDatabaseMessageId(Messages[i]) == dbMessage.Id)
+ {
+ firstUiIndexToDelete = i;
+ break;
+ }
+ }
+
+ if (firstUiIndexToDelete < 0)
+ {
+ firstUiIndexToDelete = Messages.IndexOf(uiMessage);
+ }
+
+ var messagesToRemove = Messages.Skip(firstUiIndexToDelete).ToList();
+ foreach (var msg in messagesToRemove)
+ {
+ Messages.Remove(msg);
+ if (msg is ImageMessage im)
+ {
+ im.Image?.Dispose();
+ }
+ }
+
+ // Delete all database messages from this point onward
+ var messagesToDelete = allDbMessages
+ .Where(m => m.Timestamp >= dbMessage.Timestamp)
+ .OrderBy(m => m.Timestamp)
+ .ToList();
+
+ foreach (var msg in messagesToDelete)
+ {
+ await chatService.DeleteMessageAsync(msg.Id);
+ }
+
+ // Now send the edited message
+ IsGenerating = true;
+ ErrorMessage = null;
+
+ try
+ {
+ // Add edited user message to UI
+ Messages.Add(new TextMessage(editedText, true));
+
+ // Build provider options
+ var providerOptions = BuildProviderOptions();
+
+ // Send the edited message
+ var (userMessage, assistantMessage) = await chatService.SendMessageAsync(
+ CurrentConversation!.Id,
+ SelectedProviderId!,
+ editedText,
+ null,
+ providerOptions,
+ progress: null,
+ CancellationToken.None
+ );
+
+ // Add assistant response to UI
+ if (assistantMessage != null)
+ {
+ AddAssistantMessageToUI(assistantMessage);
+ }
+
+ // Reload conversations to update timestamps
+ await LoadConversationsAsync();
+
+ notificationService.Show(
+ "Message Edited",
+ "Your message has been edited and the conversation regenerated.",
+ NotificationType.Success
+ );
+ }
+ catch (ImageGenerationException ex)
+ {
+ logger.LogWarning("Failed to regenerate after edit: {Message}", ex.Message);
+
+ if (ex.Message.Contains("API key", StringComparison.OrdinalIgnoreCase))
+ {
+ await ShowApiKeyRequiredDialogAsync();
+ CanRetryLastMessage = true;
+ }
+ else
+ {
+ ErrorMessage = ex.Message;
+ notificationService.Show("Generation Failed", ex.Message, NotificationType.Warning);
+ CanRetryLastMessage = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error regenerating after edit");
+ ErrorMessage = $"Unexpected error: {ex.Message}";
+ notificationService.Show("Error", ex.Message, NotificationType.Error);
+ CanRetryLastMessage = true;
+ }
+ finally
+ {
+ IsGenerating = false;
+ }
+ }
+
+ ///
+ /// Builds the provider options dictionary based on current settings
+ ///
+ private Dictionary BuildProviderOptions()
+ {
+ Dictionary? providerOptions = null;
+
+ if (SupportsThinking && ShowThinkingOutput)
+ {
+ providerOptions = new() { ["enableThinking"] = true, ["thinkingBudget"] = 2048 };
+ }
+
+ if (SelectedProviderId == BananaVisionProviderIds.FluxKontext)
+ {
+ providerOptions ??= new();
+ if (SelectedFluxModel != null)
+ providerOptions["CustomUnetModel"] = SelectedFluxModel;
+ if (SelectedLoras.Count > 0)
+ providerOptions["SelectedLoras"] = SelectedLoras.ToList();
+ }
+
+ if (SelectedProviderId == BananaVisionProviderIds.QwenImageEdit)
+ {
+ providerOptions ??= new();
+ if (SelectedQwenModel != null)
+ providerOptions["CustomUnetModel"] = SelectedQwenModel;
+ if (SelectedLoras.Count > 0)
+ providerOptions["SelectedLoras"] = SelectedLoras.ToList();
+ }
+
+ if (SelectedProviderId == BananaVisionProviderIds.Flux2Klein)
+ {
+ providerOptions ??= new();
+ if (SelectedKleinModel != null)
+ providerOptions["CustomUnetModel"] = SelectedKleinModel;
+ if (SelectedLoras.Count > 0)
+ providerOptions["SelectedLoras"] = SelectedLoras.ToList();
+ providerOptions["Steps"] = KleinSteps;
+ providerOptions["CfgScale"] = KleinCfg;
+ }
+
+ providerOptions ??= new();
+
+ if (UseCustomResolution)
+ {
+ providerOptions["Width"] = CustomWidth;
+ providerOptions["Height"] = CustomHeight;
+ // Marker that the user explicitly opted into a specific resolution. Providers
+ // doing img2img edits (e.g. Klein) use this to decide whether to override the
+ // reference-image-derived dimensions.
+ providerOptions["ExplicitDimensions"] = true;
+ }
+ else if (SelectedAspectRatio != null)
+ {
+ providerOptions["aspectRatio"] = SelectedAspectRatio.Ratio;
+ providerOptions["Width"] = SelectedAspectRatio.Width;
+ providerOptions["Height"] = SelectedAspectRatio.Height;
+ }
+
+ return providerOptions;
+ }
+
+ ///
+ /// Handles key down events from the message input TextBox.
+ /// Enter sends the message, Shift+Enter adds a new line.
+ ///
+ [RelayCommand]
+ private void TextBoxKeyDown(KeyEventArgs? e)
+ {
+ if (e?.Key != Key.Enter)
+ return;
+
+ // Shift+Enter = let TextBox handle it naturally (insert newline at cursor position)
+ if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ // Don't handle it - let the TextBox process the newline naturally
+ return;
+ }
+
+ // Plain Enter = send message (but only if not already generating)
+ if (!IsGenerating && SendMessageCommand.CanExecute(null))
+ {
+ e.Handled = true;
+ SendMessageCommand.Execute(null);
+ }
+ else
+ {
+ // Prevent the Enter from doing anything if we're generating
+ e.Handled = true;
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddImageAsync()
+ {
+ if (StorageProvider == null)
+ {
+ notificationService.Show("Error", "Storage provider not available", NotificationType.Error);
+ return;
+ }
+
+ try
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(
+ new()
+ {
+ Title = "Select Images",
+ AllowMultiple = true,
+ FileTypeFilter =
+ [
+ new("Images") { Patterns = ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif"] },
+ ],
+ }
+ );
+
+ if (files.Count == 0)
+ return;
+
+ foreach (var file in files)
+ {
+ var imagePath = file.Path.LocalPath;
+ var bitmap = new Bitmap(imagePath);
+
+ PendingImages.Add(new() { FilePath = imagePath, Bitmap = bitmap });
+ }
+
+ notificationService.Show(
+ "Images Added",
+ $"Added {files.Count} image(s). They will be sent with your next message.",
+ NotificationType.Success
+ );
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to add images");
+ notificationService.Show("Error", $"Failed to add images: {ex.Message}", NotificationType.Error);
+ }
+ }
+
+ [RelayCommand]
+ private void RemovePendingImage(PendingImage image)
+ {
+ PendingImages.Remove(image);
+ image.Dispose();
+ }
+
+ ///
+ /// Adds images from file paths (used by drag and drop)
+ ///
+ public void AddImagesFromPaths(IEnumerable imagePaths)
+ {
+ try
+ {
+ var pathsList = imagePaths.ToList();
+ var addedCount = 0;
+
+ foreach (var imagePath in pathsList)
+ {
+ if (!File.Exists(imagePath))
+ continue;
+
+ var bitmap = new Bitmap(imagePath);
+ PendingImages.Add(new() { FilePath = imagePath, Bitmap = bitmap });
+ addedCount++;
+ }
+
+ if (addedCount > 0)
+ {
+ notificationService.Show(
+ "Images Added",
+ $"Added {addedCount} image(s). They will be sent with your next message.",
+ NotificationType.Success
+ );
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to add images from drag and drop");
+ notificationService.Show("Error", $"Failed to add images: {ex.Message}", NotificationType.Error);
+ }
+ }
+
+ ///
+ /// Supported image extensions for clipboard paste
+ ///
+ private static readonly HashSet SupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".webp",
+ ".gif",
+ ".bmp",
+ };
+
+ ///
+ /// Tries to paste images from the clipboard. Returns true if images were pasted.
+ ///
+ public async Task TryPasteImagesFromClipboardAsync()
+ {
+ try
+ {
+ var clipboard = App.Clipboard;
+ if (clipboard == null)
+ return false;
+
+ // First, check for files in clipboard (e.g., copied from file explorer)
+ var formats = await clipboard.GetFormatsAsync();
+
+ if (formats.Contains(DataFormats.Files))
+ {
+ var data = await clipboard.GetDataAsync(DataFormats.Files);
+ if (data is IEnumerable files)
+ {
+ var imagePaths = files
+ .Select(f => f.Path.LocalPath)
+ .Where(p => SupportedImageExtensions.Contains(Path.GetExtension(p)))
+ .ToList();
+
+ if (imagePaths.Count > 0)
+ {
+ AddImagesFromPaths(imagePaths);
+ return true;
+ }
+ }
+ }
+
+ // Check for bitmap/image data in clipboard (e.g., screenshots, copied images)
+ // Try common image formats
+ foreach (
+ var format in new[] { "PNG", "image/png", "Bitmap", "DeviceIndependentBitmap", "image/bmp" }
+ )
+ {
+ if (!formats.Contains(format))
+ continue;
+
+ var data = await clipboard.GetDataAsync(format);
+ if (data is byte[] { Length: > 0 } imageBytes)
+ {
+ var tempPath = await SaveClipboardImageToTempFileAsync(imageBytes, format);
+ if (tempPath != null)
+ {
+ AddImagesFromPaths([tempPath]);
+ return true;
+ }
+ }
+ else if (data is Stream stream)
+ {
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms);
+ var bytes = ms.ToArray();
+
+ if (bytes.Length > 0)
+ {
+ var tempPath = await SaveClipboardImageToTempFileAsync(bytes, format);
+ if (tempPath != null)
+ {
+ AddImagesFromPaths([tempPath]);
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to paste images from clipboard");
+ return false;
+ }
+ }
+
+ ///
+ /// Saves clipboard image bytes to a temporary file
+ ///
+ private async Task SaveClipboardImageToTempFileAsync(byte[] imageBytes, string format)
+ {
+ try
+ {
+ var extension = format.ToLowerInvariant() switch
+ {
+ "png" or "image/png" => ".png",
+ "image/jpeg" or "jpeg" or "jpg" => ".jpg",
+ "image/bmp" or "bitmap" or "deviceindependentbitmap" => ".bmp",
+ _ => ".png",
+ };
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "StabilityMatrix", "ClipboardImages");
+ Directory.CreateDirectory(tempDir);
+
+ var shortGuid = Guid.NewGuid().ToString("N")[..8];
+ var fileName = $"clipboard_{DateTime.Now:yyyyMMdd_HHmmss}_{shortGuid}{extension}";
+ var tempPath = Path.Combine(tempDir, fileName);
+
+ await File.WriteAllBytesAsync(tempPath, imageBytes);
+
+ logger.LogInformation("Saved clipboard image to temp file: {Path}", tempPath);
+ return tempPath;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to save clipboard image to temp file");
+ return null;
+ }
+ }
+
+ [RelayCommand]
+ private void ClearPendingImages()
+ {
+ foreach (var image in PendingImages)
+ {
+ image.Dispose();
+ }
+ PendingImages.Clear();
+ }
+
+ [RelayCommand]
+ private async Task CopyMessageAsync(TextMessage message)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(message.Text))
+ return;
+
+ var clipboard = App.Clipboard;
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(message.Text);
+ notificationService.Show("Copied", "Message copied to clipboard", NotificationType.Success);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to copy message to clipboard");
+ notificationService.Show("Error", "Failed to copy message", NotificationType.Error);
+ }
+ }
+
+ [RelayCommand]
+ private async Task CopyImageToClipboardAsync(Bitmap? image)
+ {
+ if (image == null)
+ return;
+
+ try
+ {
+ if (Compat.IsWindows)
+ {
+ await WindowsClipboard.SetBitmapAsync(image);
+ notificationService.Show("Copied", "Image copied to clipboard", NotificationType.Success);
+ }
+ else
+ {
+ notificationService.Show(
+ "Not Supported",
+ "Image clipboard is only supported on Windows",
+ NotificationType.Warning
+ );
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to copy image to clipboard");
+ notificationService.Show("Error", "Failed to copy image", NotificationType.Error);
+ }
+ }
+
+ ///
+ /// Unified cancel command that stops any ongoing generation (Send, Retry, or Regenerate)
+ ///
+ [RelayCommand]
+ private void CancelGeneration()
+ {
+ // Immediately remove loading placeholder for instant UI feedback
+ if (currentLoadingMessage != null)
+ {
+ Messages.Remove(currentLoadingMessage);
+ currentLoadingMessage = null;
+ }
+
+ // Cancel whichever operation is in progress
+ if (SendMessageCancelCommand.CanExecute(null))
+ {
+ SendMessageCancelCommand.Execute(null);
+ }
+ if (RetryLastMessageCancelCommand.CanExecute(null))
+ {
+ RetryLastMessageCancelCommand.Execute(null);
+ }
+ if (RegenerateLastResponseCancelCommand.CanExecute(null))
+ {
+ RegenerateLastResponseCancelCommand.Execute(null);
+ }
+ }
+
+ [RelayCommand]
+ private void ToggleGallery()
+ {
+ IsGalleryVisible = !IsGalleryVisible;
+ if (IsGalleryVisible)
+ {
+ OnPropertyChanged(nameof(ConversationImages));
+ }
+ }
+
+ [RelayCommand]
+ private async Task EditPendingImageAsync(PendingImage image)
+ {
+ try
+ {
+ var editorVm = vmFactory.Get();
+ editorVm.LoadImage(image.Bitmap, image.FilePath);
+
+ var dialog = editorVm.GetDialog();
+ var result = await dialog.ShowAsync();
+
+ if (result == FluentAvalonia.UI.Controls.ContentDialogResult.Primary && editorVm.HasAnnotations)
+ {
+ // Save the annotated image to a temp file
+ var annotatedPath = await editorVm.SaveAnnotatedImageAsync();
+
+ if (annotatedPath != null)
+ {
+ // Replace the pending image with the annotated version
+ var index = PendingImages.IndexOf(image);
+ if (index >= 0)
+ {
+ var annotatedBitmap = new Bitmap(annotatedPath);
+ var oldImage = PendingImages[index];
+ PendingImages[index] = new() { FilePath = annotatedPath, Bitmap = annotatedBitmap };
+ oldImage.Dispose(); // Dispose the old bitmap
+
+ notificationService.Show(
+ "Image Updated",
+ "Your annotations have been applied to the image.",
+ NotificationType.Success
+ );
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to edit image");
+ notificationService.Show("Error", $"Failed to edit image: {ex.Message}", NotificationType.Error);
+ }
+ }
+
+ ///
+ /// Preview an image in a full-size dialog
+ ///
+ [RelayCommand]
+ private async Task PreviewImageAsync(Bitmap? bitmap)
+ {
+ if (bitmap == null)
+ return;
+
+ try
+ {
+ var viewerVm = vmFactory.Get();
+ viewerVm.ImageSource = new(bitmap);
+
+ var dialog = viewerVm.GetDialog();
+ await dialog.ShowAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to preview image");
+ }
+ }
+
+ partial void OnSelectedProviderIdChanged(string? value)
+ {
+ // Log provider change - the actual conversation update happens when sending a message
+ if (CurrentConversation != null && value != null && value != CurrentConversation.ProviderId)
+ {
+ logger.LogInformation(
+ "Provider selection changed from {OldProvider} to {NewProvider} for conversation {ConversationId}",
+ CurrentConversation.ProviderId,
+ value,
+ CurrentConversation.Id
+ );
+ }
+
+ // If switching away from local providers, clean up any pending connection
+ if (!BananaVisionProviderIds.IsLocalProvider(value))
+ {
+ startupCompleteSubscription?.Dispose();
+ startupCompleteSubscription = null;
+ IsWaitingForConnection = false;
+ hasShownMissingModelsDialog = false; // Reset for next time
+ }
+
+ // Update provider status for the new provider
+ UpdateProviderStatus();
+
+ // Notify that provider-related properties may have changed
+ OnPropertyChanged(nameof(SupportsThinking));
+ OnPropertyChanged(nameof(RequiresLocalBackend));
+ OnPropertyChanged(nameof(IsCloudProvider));
+ OnPropertyChanged(nameof(ShowFluxSettings));
+ OnPropertyChanged(nameof(ShowQwenSettings));
+ OnPropertyChanged(nameof(ShowKleinSettings));
+
+ // Load available Flux models when switching to Flux Kontext
+ if (value == BananaVisionProviderIds.FluxKontext)
+ {
+ LoadAvailableFluxModels();
+
+ // Auto-show missing models dialog if connected and models are missing
+ CheckAndShowMissingModelsDialogAsync()
+ .SafeFireAndForget(ex =>
+ {
+ logger.LogError(ex, "Failed to check for missing Flux models");
+ });
+ }
+
+ // Load available Qwen models when switching to Qwen Image Edit
+ if (value == BananaVisionProviderIds.QwenImageEdit)
+ {
+ LoadAvailableQwenModels();
+
+ // Auto-show missing models dialog if connected and models are missing
+ CheckAndShowMissingModelsDialogAsync()
+ .SafeFireAndForget(ex =>
+ {
+ logger.LogError(ex, "Failed to check for missing Qwen models");
+ });
+ }
+
+ // Load available Klein models when switching to Flux.2 Klein
+ if (value == BananaVisionProviderIds.Flux2Klein)
+ {
+ LoadAvailableKleinModels();
+
+ // Auto-show missing models dialog if connected and models are missing
+ CheckAndShowMissingModelsDialogAsync()
+ .SafeFireAndForget(ex =>
+ {
+ logger.LogError(ex, "Failed to check for missing Klein models");
+ });
+ }
+ }
+
+ private void UpdateProviderStatus()
+ {
+ // Check if this is a local provider with model requirements
+ var modelManager = LocalProviderModelManagerRegistry.GetManager(SelectedProviderId);
+
+ if (modelManager != null)
+ {
+ // This is a local provider - check ComfyUI and model status
+
+ // Check if ComfyUI is running
+ if (!IsComfyRunning)
+ {
+ ProviderStatusMessage = "β οΈ ComfyUI is not running. Click Launch to start.";
+ IsFluxKontextAvailable = false;
+ HasMissingModels = false;
+ return;
+ }
+
+ // Check if we're waiting for connection
+ if (IsWaitingForConnection)
+ {
+ ProviderStatusMessage = "π Connecting to ComfyUI...";
+ IsFluxKontextAvailable = false;
+ HasMissingModels = false;
+ return;
+ }
+
+ // Check ComfyUI connection status
+ if (!ClientManager.IsConnected)
+ {
+ ProviderStatusMessage = "β οΈ Not connected to ComfyUI. Click Connect.";
+ IsFluxKontextAvailable = false;
+ HasMissingModels = false;
+ return;
+ }
+
+ // While a model-download batch is running, show progress instead of the
+ // missing-models warning. Models are still technically missing on disk until
+ // the download finishes, but the user has already acted on that β surfacing
+ // the same warning + Download button would be misleading.
+ if (IsDownloadingModels)
+ {
+ ProviderStatusMessage = DownloadProgressText ?? "β¬οΈ Downloading models...";
+ IsFluxKontextAvailable = false;
+ HasMissingModels = false;
+ return;
+ }
+
+ // Check if required models are available
+ if (!modelManager.AreModelsAvailable(ClientManager))
+ {
+ var missingModelNames = modelManager.GetMissingModelNames(ClientManager).ToList();
+ var modelsList = string.Join(", ", missingModelNames);
+ ProviderStatusMessage = $"β οΈ Missing: {modelsList}";
+ IsFluxKontextAvailable = false;
+ HasMissingModels = true;
+ return;
+ }
+
+ // All good
+ ProviderStatusMessage = $"β
{modelManager.ProviderDisplayName} is ready";
+ IsFluxKontextAvailable = true;
+ HasMissingModels = false;
+ }
+ else
+ {
+ // Cloud providers or providers without model requirements
+ ProviderStatusMessage = null;
+ IsFluxKontextAvailable = false;
+ HasMissingModels = false;
+ }
+ }
+
+ ///
+ /// Gets all valid image paths from a message, handling both ImagePath and ImagePaths properties
+ ///
+ private static List GetMessageImagePaths(ImageGenerationMessage message)
+ {
+ var paths =
+ message.ImagePaths?.Where(p => !string.IsNullOrWhiteSpace(p)).ToList()
+ ?? (!string.IsNullOrEmpty(message.ImagePath) ? [message.ImagePath] : []);
+
+ return paths.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// Adds a message (user or assistant) to the Messages collection
+ ///
+ /// The message to add
+ /// Whether to include the database message ID for tracking
+ /// True if any images were added
+ private bool AddMessageToUI(ImageGenerationMessage message, bool includeDbId = true)
+ {
+ var isUserMessage = message.Role == MessageRole.User;
+ var dbId = includeDbId ? message.Id : (Guid?)null;
+
+ // Show thinking content first (only for assistant messages)
+ if (!isUserMessage && ShowThinkingOutput && !string.IsNullOrEmpty(message.ThinkingContent))
+ {
+ Messages.Add(new ThinkingMessage(message.ThinkingContent) { DatabaseMessageId = dbId });
+ }
+
+ if (!string.IsNullOrEmpty(message.TextContent))
+ {
+ Messages.Add(new TextMessage(message.TextContent, isUserMessage) { DatabaseMessageId = dbId });
+ }
+
+ var addedAnyImages = false;
+ foreach (var imagePath in GetMessageImagePaths(message).Where(File.Exists))
+ {
+ var bitmap = new Bitmap(imagePath);
+ Messages.Add(
+ new ImageMessage(bitmap, isUserMessage) { DatabaseMessageId = dbId, FilePath = imagePath }
+ );
+ addedAnyImages = true;
+ }
+
+ return addedAnyImages;
+ }
+
+ ///
+ /// Adds an assistant message (thinking, text, and images) to the Messages collection
+ ///
+ private bool AddAssistantMessageToUI(ImageGenerationMessage message, bool includeDbId = true)
+ {
+ return AddMessageToUI(message, includeDbId);
+ }
+
+ ///
+ /// Adds a user message (text and images) to the Messages collection
+ ///
+ private void AddUserMessageToUI(ImageGenerationMessage message)
+ {
+ AddMessageToUI(message, includeDbId: true);
+ }
+
+ ///
+ /// Clears all messages and disposes any image bitmaps to prevent memory leaks
+ ///
+ private void ClearMessages()
+ {
+ foreach (var message in Messages)
+ {
+ if (message is ImageMessage imageMessage)
+ {
+ imageMessage.Image?.Dispose();
+ }
+ }
+ Messages.Clear();
+ }
+
+ private static Guid? GetDatabaseMessageId(object? message)
+ {
+ return message switch
+ {
+ MessageBase m => m.DatabaseMessageId,
+ ThinkingMessage tm => tm.DatabaseMessageId,
+ _ => null,
+ };
+ }
+
+ private void RemoveUiMessagesForDatabaseMessageId(Guid messageId)
+ {
+ var toRemove = Messages.Where(m => GetDatabaseMessageId(m) == messageId).ToList();
+
+ foreach (var item in toRemove)
+ {
+ Messages.Remove(item);
+ if (item is ImageMessage imageMessage)
+ {
+ imageMessage.Image?.Dispose();
+ }
+ }
+
+ // Notify gallery that images may have changed
+ OnPropertyChanged(nameof(ConversationImages));
+ OnPropertyChanged(nameof(HasConversationImages));
+ OnPropertyChanged(nameof(CanRegenerateLastResponse));
+ }
+
+ [RelayCommand]
+ private async Task DeleteMessageAsync(object? messageItem)
+ {
+ if (CurrentConversation == null)
+ return;
+
+ var messageId = GetDatabaseMessageId(messageItem);
+ if (messageId == null)
+ return;
+
+ try
+ {
+ // Check if this is an image from a multi-image message
+ var isImageMessage =
+ messageItem is ImageMessage imageMsg && !string.IsNullOrEmpty(imageMsg.FilePath);
+ var dbMessage = isImageMessage ? await chatService.GetMessageAsync(messageId.Value) : null;
+ var imageCount =
+ dbMessage != null
+ ? (dbMessage.ImagePaths?.Count ?? (string.IsNullOrEmpty(dbMessage.ImagePath) ? 0 : 1))
+ : 0;
+ var isMultiImageMessage = imageCount > 1;
+
+ string dialogContent;
+ if (isMultiImageMessage)
+ {
+ dialogContent =
+ "Delete this image from the message?\n\n"
+ + $"The message has {imageCount} images. Only this image will be removed.";
+ }
+ else
+ {
+ dialogContent =
+ "This will permanently delete the selected message from this conversation.\n\n"
+ + "Note: deleting a message in the middle of a conversation may change context for future generations.";
+ }
+
+ var dialog = new ContentDialog
+ {
+ Title = isMultiImageMessage ? "Delete image?" : "Delete message?",
+ Content = new TextBlock
+ {
+ Text = dialogContent,
+ TextWrapping = global::Avalonia.Media.TextWrapping.Wrap,
+ MaxWidth = 420,
+ },
+ PrimaryButtonText = "Delete",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ };
+
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary)
+ return;
+
+ // Handle multi-image message: only remove the specific image
+ if (
+ isMultiImageMessage
+ && messageItem is ImageMessage imgToDelete
+ && !string.IsNullOrEmpty(imgToDelete.FilePath)
+ )
+ {
+ var wasFullyDeleted = await chatService.RemoveImageFromMessageAsync(
+ messageId.Value,
+ imgToDelete.FilePath
+ );
+
+ if (wasFullyDeleted)
+ {
+ // Whole message was deleted (was the last image)
+ RemoveUiMessagesForDatabaseMessageId(messageId.Value);
+ }
+ else
+ {
+ // Only remove this specific UI element
+ Messages.Remove(messageItem);
+ }
+ }
+ else
+ {
+ // Regular deletion - remove entire message
+ await chatService.DeleteMessageAsync(messageId.Value);
+ RemoveUiMessagesForDatabaseMessageId(messageId.Value);
+ }
+
+ // Reload conversations to update timestamps
+ await LoadConversationsAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete message {MessageId}", messageId);
+ notificationService.Show(
+ "Error",
+ $"Failed to delete message: {ex.Message}",
+ NotificationType.Error
+ );
+ }
+ }
+
+ ///
+ /// Handles collection changes to trigger auto-scroll
+ ///
+ private void OnMessagesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ // Request scroll to end when new messages are added
+ if (e.Action == NotifyCollectionChangedAction.Add)
+ {
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ ScrollToEndRequested?.Invoke(this, EventArgs.Empty);
+ },
+ DispatcherPriority.Background
+ );
+ }
+
+ // Notify that regenerate availability may have changed
+ OnPropertyChanged(nameof(CanRegenerateLastResponse));
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // Cancel and dispose any pending message load
+ loadMessagesCts?.Cancel();
+ loadMessagesCts?.Dispose();
+ loadMessagesCts = null;
+
+ // Dispose startup subscription
+ startupCompleteSubscription?.Dispose();
+ startupCompleteSubscription = null;
+
+ // Dispose pending images
+ foreach (var image in PendingImages)
+ {
+ image.Dispose();
+ }
+ PendingImages.Clear();
+
+ // Dispose message bitmaps and clear
+ ClearMessages();
+
+ // Unsubscribe from collection changed
+ Messages.CollectionChanged -= OnMessagesCollectionChanged;
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
index 41537a3e4..0868417f9 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs
@@ -39,6 +39,7 @@
using StabilityMatrix.Core.Models.Api.Comfy.WebSocketData;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Inference;
+using StabilityMatrix.Core.Models.Notifications;
using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Packages.Extensions;
using StabilityMatrix.Core.Models.Settings;
@@ -60,8 +61,8 @@ public abstract partial class InferenceGenerationViewModelBase
private readonly ISettingsManager settingsManager;
private readonly RunningPackageService runningPackageService;
- private readonly INotificationService notificationService;
private readonly IServiceManager vmFactory;
+ private readonly INotificationService notificationService;
[JsonPropertyName("ImageGallery")]
public ImageGalleryCardViewModel ImageGalleryCardViewModel { get; }
@@ -411,7 +412,8 @@ await notificationService.ShowAsync(
Title = "Prompt Completed",
Body = $"Prompt [{promptTask.Id[..7].ToLower()}] completed successfully",
BodyImagePath = notificationImage?.FullPath,
- }
+ },
+ action: new NavigateToPageAction(typeof(InferenceViewModel).AssemblyQualifiedName!)
);
}
finally
diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
index 26a875029..ec75b4b32 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Base/LoadableViewModelBase.cs
@@ -45,6 +45,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Base;
[JsonDerivedType(typeof(NRSModule))]
[JsonDerivedType(typeof(CfzCudnnToggleModule))]
[JsonDerivedType(typeof(TiledVAEModule))]
+[JsonDerivedType(typeof(RegionalPromptModule))]
+[JsonDerivedType(typeof(RegionalPromptCardViewModel), RegionalPromptCardViewModel.ModuleKey)]
public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs
new file mode 100644
index 000000000..f39219a87
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs
@@ -0,0 +1,890 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Reactive.Disposables;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using FluentAvalonia.UI.Controls;
+using Injectio.Attributes;
+using StabilityMatrix.Avalonia.Animations;
+using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
+using StabilityMatrix.Avalonia.Views;
+using StabilityMatrix.Core.Api;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models.Api.CivArchive;
+using StabilityMatrix.Core.Models.Settings;
+using StabilityMatrix.Core.Processes;
+using StabilityMatrix.Core.Services;
+
+namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
+
+[View(typeof(CivArchiveBrowserPage))]
+[RegisterSingleton]
+public sealed partial class CivArchiveBrowserViewModel(
+ ICivArchiveApiClient civArchiveApiClient,
+ ISettingsManager settingsManager,
+ IServiceManager viewModelFactory,
+ INavigationService navigationService,
+ IModelIndexService modelIndexService
+) : TabViewModelBase, IInfinitelyScroll
+{
+ private bool suppressSearch;
+ private bool filterOptionsLoaded;
+ private bool searchQueued;
+ private int currentPage = 1;
+ private CancellationTokenSource? searchDebounceCts;
+
+ ///
+ /// How long to wait after the most recent filter change before firing the search.
+ /// Kept settable (not a const) so unit tests can collapse it to
+ /// instead of waiting hundreds of ms per assertion.
+ ///
+ public TimeSpan SearchDebounceInterval { get; set; } = TimeSpan.FromMilliseconds(300);
+
+ ///
+ /// All search results we've fetched so far across pages, regardless of client-side filters.
+ /// Used as the source for rebuilds and dedupe checks.
+ ///
+ private readonly List rawResults = [];
+
+ [ObservableProperty]
+ private ObservableCollection results = [];
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsEndOfResults), nameof(HasResultCount))]
+ private int totalHits;
+
+ [ObservableProperty]
+ private bool hideInstalledModels;
+
+ [ObservableProperty]
+ private double resizeFactor = 1.0;
+
+ ///
+ /// True (default) renders card images in Fit mode (whole image visible with a blurred
+ /// edge-fill behind it). False switches to Fill mode (UniformToFill, may crop edges).
+ ///
+ [ObservableProperty]
+ private bool fitCardImages = true;
+
+ public bool IsEndOfResults => HasSearched && TotalHits > 0 && rawResults.Count >= TotalHits;
+
+ public bool HasResultCount => HasSearched && TotalHits > 0;
+
+ [ObservableProperty]
+ private ObservableCollection allModelTypes = [];
+
+ [ObservableProperty]
+ private ObservableCollection allBaseModels = [];
+
+ [ObservableProperty]
+ private ObservableCollection filteredModelTypes = [];
+
+ [ObservableProperty]
+ private ObservableCollection filteredBaseModels = [];
+
+ [ObservableProperty]
+ private string modelTypeFilter = string.Empty;
+
+ [ObservableProperty]
+ private string baseModelFilter = string.Empty;
+
+ public string ModelTypeSelectionSummary =>
+ AllModelTypes.Count == 0
+ ? string.Empty
+ : $"{AllModelTypes.Count(x => x.IsSelected)} of {AllModelTypes.Count} selected";
+
+ public string BaseModelSelectionSummary =>
+ AllBaseModels.Count == 0
+ ? string.Empty
+ : $"{AllBaseModels.Count(x => x.IsSelected)} of {AllBaseModels.Count} selected";
+
+ ///
+ /// True when a partial selection is active β at least one selected and at least one
+ /// deselected. The "all selected" stock state and the "none selected" degenerate state
+ /// both render plain (no badge), since neither is a meaningful filter to surface.
+ ///
+ public bool HasModelTypeFilter =>
+ AllModelTypes.Count > 0
+ && AllModelTypes.Any(x => x.IsSelected)
+ && AllModelTypes.Any(x => !x.IsSelected);
+
+ public bool HasBaseModelFilter =>
+ AllBaseModels.Count > 0
+ && AllBaseModels.Any(x => x.IsSelected)
+ && AllBaseModels.Any(x => !x.IsSelected);
+
+ public int SelectedModelTypeCount => AllModelTypes.Count(x => x.IsSelected);
+
+ public int SelectedBaseModelCount => AllBaseModels.Count(x => x.IsSelected);
+
+ [ObservableProperty]
+ private string searchQuery = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasAdvancedFilters))]
+ private string tagQuery = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasAdvancedFilters))]
+ private string usernameQuery = string.Empty;
+
+ ///
+ /// True when the user has set explicit Tags or Username via the "More filters" flyout
+ /// (not the inline @/# tokens β those are visible in the search box itself).
+ /// Used to show a small dot indicator on the More Filters button.
+ ///
+ public bool HasAdvancedFilters =>
+ !string.IsNullOrWhiteSpace(TagQuery) || !string.IsNullOrWhiteSpace(UsernameQuery);
+
+ [ObservableProperty]
+ private bool isLoading;
+
+ [ObservableProperty]
+ private bool noResultsFound;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsEndOfResults), nameof(HasResultCount))]
+ private bool hasSearched;
+
+ [ObservableProperty]
+ private string noResultsText = "No results found";
+
+ [ObservableProperty]
+ private NamedOption? selectedPlatform;
+
+ [ObservableProperty]
+ private NamedOption? selectedSort;
+
+ [ObservableProperty]
+ private NamedOption? selectedPeriod;
+
+ [ObservableProperty]
+ private NamedOption? selectedRating;
+
+ [ObservableProperty]
+ private NamedOption? selectedPlatformStatus;
+
+ [ObservableProperty]
+ private NamedOption? selectedKind;
+
+ public IReadOnlyList> AllPlatforms { get; } =
+ [
+ new("All Platforms", CivArchivePlatformOption.All),
+ new("CivitAI", CivArchivePlatformOption.Civitai),
+ new("TensorArt", CivArchivePlatformOption.Tensorart),
+ new("TensorHub", CivArchivePlatformOption.Tensorhub),
+ new("SeaArt", CivArchivePlatformOption.Seaart),
+ new("Civision", CivArchivePlatformOption.Civision),
+ new("PixAI", CivArchivePlatformOption.Pixai),
+ new("Tungsten", CivArchivePlatformOption.Tungsten),
+ new("Yodayo", CivArchivePlatformOption.Yodayo),
+ new("Moescape", CivArchivePlatformOption.Moescape),
+ new("Shakker", CivArchivePlatformOption.Shakker),
+ new("HuggingFace", CivArchivePlatformOption.Huggingface),
+ new("ModelScope", CivArchivePlatformOption.Modelscope),
+ new("ModelScope CN", CivArchivePlatformOption.ModelscopeCn),
+ ];
+
+ public IReadOnlyList> AllSorts { get; } =
+ [
+ new("Top", CivArchiveSortOption.Top),
+ new("Newest", CivArchiveSortOption.Newest),
+ new("Oldest", CivArchiveSortOption.Oldest),
+ new("Relevance", CivArchiveSortOption.Relevance),
+ new("Deleted Newest", CivArchiveSortOption.DeletedNewest),
+ new("Deleted Oldest", CivArchiveSortOption.DeletedOldest),
+ ];
+
+ public IReadOnlyList> AllPeriods { get; } =
+ [
+ new("All", CivArchivePeriodOption.All),
+ new("Week", CivArchivePeriodOption.Week),
+ new("Month", CivArchivePeriodOption.Month),
+ new("Quarter", CivArchivePeriodOption.Quarter),
+ new("Half", CivArchivePeriodOption.Half),
+ new("Year", CivArchivePeriodOption.Year),
+ ];
+
+ public IReadOnlyList> AllRatings { get; } =
+ [
+ new("Safe", CivArchiveRatingOption.Safe),
+ new("All", CivArchiveRatingOption.All),
+ new("Explicit", CivArchiveRatingOption.Explicit),
+ ];
+
+ public IReadOnlyList> AllPlatformStatuses { get; } =
+ [
+ new("All", CivArchivePlatformStatusOption.All),
+ new("Available", CivArchivePlatformStatusOption.Available),
+ new("Deleted", CivArchivePlatformStatusOption.Deleted),
+ ];
+
+ public IReadOnlyList> AllKinds { get; } =
+ [
+ new("All", CivArchiveKindOption.All),
+ new("Version", CivArchiveKindOption.Version),
+ new("User", CivArchiveKindOption.User),
+ new("File", CivArchiveKindOption.File),
+ ];
+
+ public override string Header => "CivArchive";
+
+ public override void OnLoaded()
+ {
+ if (!ViewModelState.HasFlag(ViewModelState.InitialLoaded))
+ {
+ RestoreSettings();
+ }
+
+ base.OnLoaded();
+ }
+
+ protected override async Task OnInitialLoadedAsync()
+ {
+ await base.OnInitialLoadedAsync();
+
+ AddDisposable(
+ settingsManager.RelayPropertyFor(
+ this,
+ vm => vm.ResizeFactor,
+ s => s.CivArchiveBrowserResizeFactor,
+ true
+ )
+ );
+
+ AddDisposable(
+ settingsManager.RelayPropertyFor(
+ this,
+ vm => vm.HideInstalledModels,
+ s => s.HideInstalledModelsInModelBrowser,
+ true
+ )
+ );
+
+ AddDisposable(
+ settingsManager.RelayPropertyFor(
+ this,
+ vm => vm.FitCardImages,
+ s => s.CivArchiveBrowserFitCardImages,
+ true
+ )
+ );
+
+ EventHandler indexHandler = (_, _) => Dispatcher.UIThread.Post(OnLocalModelIndexChanged);
+ EventManager.Instance.ModelIndexChanged += indexHandler;
+ AddDisposable(Disposable.Create(() => EventManager.Instance.ModelIndexChanged -= indexHandler));
+
+ // Cancel any pending debounced search when the VM is disposed.
+ AddDisposable(
+ Disposable.Create(() =>
+ {
+ searchDebounceCts?.Cancel();
+ searchDebounceCts?.Dispose();
+ searchDebounceCts = null;
+ })
+ );
+
+ // Filter options (Model Type / Base Model dropdown contents) only come back
+ // populated when the URL has no query string, so we have to fetch them with a
+ // dedicated parameterless call before the first filtered search runs.
+ await LoadFilterOptionsAsync();
+
+ await SearchModels();
+ }
+
+ private async Task LoadFilterOptionsAsync()
+ {
+ if (filterOptionsLoaded)
+ {
+ return;
+ }
+
+ try
+ {
+ var options = await civArchiveApiClient.GetFilterOptionsAsync();
+ ApplyFilterOptions(options);
+ filterOptionsLoaded = true;
+ }
+ catch (Exception ex)
+ {
+ // Don't block the search itself β failure here just means the multi-select
+ // dropdowns stay empty until the user reloads the page.
+ NoResultsText = ex.Message;
+ }
+ finally
+ {
+ // ApplyFilterOptions sets suppressSearch=true while it populates the option
+ // collections; release it before the real search runs so user input flows.
+ suppressSearch = false;
+ }
+ }
+
+ partial void OnHideInstalledModelsChanged(bool value) => RebuildVisibleResults();
+
+ private void OnLocalModelIndexChanged()
+ {
+ // The local index changed (download finished or model deleted) β re-evaluate every
+ // cached result's IsInstalled flag, then rebuild Results so the badge / hide-installed
+ // filter reflect reality without needing a re-search.
+ var hashes = modelIndexService.ModelIndexSha256Hashes;
+ var urls = modelIndexService.ModelIndexCivArchiveUrls;
+ foreach (var item in rawResults)
+ {
+ item.IsInstalled =
+ (!string.IsNullOrEmpty(item.Url) && urls.Contains(item.Url))
+ || (!string.IsNullOrEmpty(item.Sha256FromUrl) && hashes.Contains(item.Sha256FromUrl));
+ }
+ RebuildVisibleResults();
+ }
+
+ private void RebuildVisibleResults()
+ {
+ // Replace the collection wholesale instead of Clear + NΓAdd β one
+ // PropertyChanged on Results vs N CollectionChanged notifications,
+ // which keeps ItemsRepeater from churning containers per item.
+ Results = new ObservableCollection(
+ HideInstalledModels ? rawResults.Where(item => !item.IsInstalled) : rawResults
+ );
+ NoResultsFound = HasSearched && Results.Count == 0;
+ OnPropertyChanged(nameof(IsEndOfResults));
+ }
+
+ ///
+ /// Cancel any pending debounced search and start a new wait window. The actual
+ /// search runs once the user pauses for β
+ /// multiple rapid filter changes within that window collapse into a single fetch.
+ ///
+ private void RequestDebouncedSearch()
+ {
+ if (suppressSearch)
+ {
+ return;
+ }
+
+ SaveSettings();
+
+ if (!HasSearched)
+ {
+ return;
+ }
+
+ searchDebounceCts?.Cancel();
+ searchDebounceCts?.Dispose();
+
+ var cts = new CancellationTokenSource();
+ searchDebounceCts = cts;
+
+ _ = RunDebouncedSearchAsync(cts.Token);
+ }
+
+ private async Task RunDebouncedSearchAsync(CancellationToken token)
+ {
+ try
+ {
+ await Task.Delay(SearchDebounceInterval, token);
+ }
+ catch (TaskCanceledException)
+ {
+ return;
+ }
+
+ if (token.IsCancellationRequested)
+ {
+ return;
+ }
+
+ await SearchModels();
+ }
+
+ [RelayCommand]
+ private async Task SearchModels(bool isInfiniteScroll = false)
+ {
+ // Cancel any pending debounced search so an explicit invocation (search button,
+ // ResetFilters, etc.) doesn't get shadowed by a redundant fire moments later.
+ searchDebounceCts?.Cancel();
+
+ if (IsLoading)
+ {
+ if (!isInfiniteScroll)
+ {
+ searchQueued = true;
+ }
+
+ return;
+ }
+
+ if (!isInfiniteScroll)
+ {
+ searchQueued = false;
+ }
+
+ if (!isInfiniteScroll)
+ {
+ currentPage = 1;
+ TotalHits = 0;
+ rawResults.Clear();
+ Results.Clear();
+ }
+
+ var filters = BuildFilters(isInfiniteScroll ? currentPage + 1 : currentPage);
+
+ IsLoading = true;
+ NoResultsFound = false;
+ NoResultsText = "No results found";
+
+ try
+ {
+ var response = await civArchiveApiClient.SearchAsync(filters);
+
+ TotalHits = response.TotalHits;
+ currentPage = response.EffectiveFilters.Page;
+
+ // O(1) dedupe lookup against everything we've already fetched, instead of a
+ // linear scan per incoming item (which becomes O(NΒ²) as paged results grow).
+ var existingIds = isInfiniteScroll ? rawResults.Select(x => x.Id).ToHashSet() : [];
+ var installedHashes = modelIndexService.ModelIndexSha256Hashes;
+ var installedUrls = modelIndexService.ModelIndexCivArchiveUrls;
+ foreach (var item in response.Results)
+ {
+ if (isInfiniteScroll && !existingIds.Add(item.Id))
+ {
+ continue;
+ }
+
+ // URL match works for any CivArchive download with a stored SourceUrl;
+ // SHA256 fallback covers the rare File-kind result with hash in URL.
+ if (!string.IsNullOrEmpty(item.Url) && installedUrls.Contains(item.Url))
+ {
+ item.IsInstalled = true;
+ }
+ else if (
+ !string.IsNullOrEmpty(item.Sha256FromUrl) && installedHashes.Contains(item.Sha256FromUrl)
+ )
+ {
+ item.IsInstalled = true;
+ }
+
+ rawResults.Add(item);
+ if (!HideInstalledModels || !item.IsInstalled)
+ {
+ Results.Add(item);
+ }
+ }
+
+ HasSearched = true;
+ NoResultsFound = Results.Count == 0;
+ OnPropertyChanged(nameof(IsEndOfResults));
+ }
+ catch (Exception ex)
+ {
+ NoResultsFound = Results.Count == 0;
+ NoResultsText = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ SaveSettings();
+ }
+
+ if (searchQueued)
+ {
+ searchQueued = false;
+ await SearchModels();
+ }
+ }
+
+ [RelayCommand]
+ private void ClearSearchQuery()
+ {
+ SearchQuery = string.Empty;
+ }
+
+ [RelayCommand]
+ private async Task OpenResult(CivArchiveSearchResult? result)
+ {
+ if (result is null)
+ {
+ return;
+ }
+
+ switch (result.Kind)
+ {
+ case CivArchiveKindOption.User:
+ await PivotToUser(result);
+ break;
+ case CivArchiveKindOption.File:
+ await OpenFileResult(result);
+ break;
+ default:
+ NavigateToDetails(result.Url);
+ break;
+ }
+ }
+
+ ///
+ /// File-kind results have a /sha256/{hash} URL whose endpoint returns a different
+ /// shape than the model details page. Resolve the SHA256 to its linked model + version
+ /// and navigate there in-app; if no model is linked (orphaned hash), fall back to opening
+ /// the URL externally.
+ ///
+ private async Task OpenFileResult(CivArchiveSearchResult result)
+ {
+ var resolvedUrl = await civArchiveApiClient.ResolveFileUrlAsync(result.Url);
+ if (!string.IsNullOrWhiteSpace(resolvedUrl))
+ {
+ NavigateToDetails(resolvedUrl);
+ }
+ else
+ {
+ ProcessRunner.OpenUrl(civArchiveApiClient.GetAbsoluteUri(result.Url).ToString());
+ }
+ }
+
+ private void NavigateToDetails(string relativeUrl)
+ {
+ var detailsVm = viewModelFactory.Get(vm =>
+ {
+ vm.RelativeUrl = relativeUrl;
+ return vm;
+ });
+ navigationService.NavigateTo(detailsVm, BetterSlideNavigationTransition.PageSlideFromRight);
+ }
+
+ [RelayCommand]
+ private void OpenOnCivArchive(CivArchiveSearchResult? result)
+ {
+ if (result is not null)
+ {
+ ProcessRunner.OpenUrl(civArchiveApiClient.GetAbsoluteUri(result.Url).ToString());
+ }
+ }
+
+ [RelayCommand]
+ private async Task SearchByCreator(CivArchiveSearchResult? result)
+ {
+ if (string.IsNullOrWhiteSpace(result?.Username))
+ {
+ return;
+ }
+
+ UsernameQuery = result.Username;
+ await SearchModels();
+ }
+
+ [RelayCommand]
+ private async Task CopySha256(CivArchiveSearchResult? result)
+ {
+ if (!string.IsNullOrWhiteSpace(result?.Sha256FromUrl) && App.Clipboard is not null)
+ {
+ await App.Clipboard.SetTextAsync(result.Sha256FromUrl);
+ }
+ }
+
+ [RelayCommand]
+ private void ToggleAllModelTypes()
+ {
+ var shouldSelectAll = AllModelTypes.Any(x => !x.IsSelected);
+ suppressSearch = true;
+ try
+ {
+ foreach (var option in AllModelTypes)
+ {
+ option.IsSelected = shouldSelectAll;
+ }
+ }
+ finally
+ {
+ suppressSearch = false;
+ }
+ TriggerFilterSearch();
+ }
+
+ [RelayCommand]
+ private void ToggleAllBaseModels()
+ {
+ var shouldSelectAll = AllBaseModels.Any(x => !x.IsSelected);
+ suppressSearch = true;
+ try
+ {
+ foreach (var option in AllBaseModels)
+ {
+ option.IsSelected = shouldSelectAll;
+ }
+ }
+ finally
+ {
+ suppressSearch = false;
+ }
+ TriggerFilterSearch();
+ }
+
+ ///
+ /// Reset every filter back to its default. Single property setter at the end re-triggers
+ /// the search instead of one fetch per change.
+ ///
+ [RelayCommand]
+ private async Task ResetFilters()
+ {
+ suppressSearch = true;
+ try
+ {
+ SearchQuery = string.Empty;
+ TagQuery = string.Empty;
+ UsernameQuery = string.Empty;
+ SelectedPlatform = AllPlatforms.First(x => x.Value == CivArchivePlatformOption.All);
+ SelectedSort = AllSorts.First(x => x.Value == CivArchiveSortOption.Top);
+ SelectedPeriod = AllPeriods.First(x => x.Value == CivArchivePeriodOption.All);
+ SelectedRating = AllRatings.First(x => x.Value == CivArchiveRatingOption.Safe);
+ SelectedPlatformStatus = AllPlatformStatuses.First(x =>
+ x.Value == CivArchivePlatformStatusOption.All
+ );
+ SelectedKind = AllKinds.First(x => x.Value == CivArchiveKindOption.All);
+ foreach (var option in AllModelTypes)
+ option.IsSelected = true;
+ foreach (var option in AllBaseModels)
+ option.IsSelected = true;
+ }
+ finally
+ {
+ suppressSearch = false;
+ }
+
+ await SearchModels();
+ }
+
+ public async Task LoadNextPageAsync()
+ {
+ // Compare against rawResults so infinite-scroll keeps fetching even when
+ // HideInstalledModels filters items out of Results.
+ if (!IsLoading && rawResults.Count < TotalHits)
+ {
+ await SearchModels(true);
+ }
+ }
+
+ private async Task PivotToUser(CivArchiveSearchResult result)
+ {
+ UsernameQuery = !string.IsNullOrWhiteSpace(result.Username) ? result.Username : result.Name;
+ await SearchModels();
+ }
+
+ private void ApplyFilterOptions(CivArchiveFilterOptions options)
+ {
+ var savedOptions = settingsManager.Settings.CivArchiveBrowserOptions;
+
+ suppressSearch = true;
+ SetSelectableOptions(AllModelTypes, options.ModelTypes, savedOptions.SelectedModelTypes);
+ SetSelectableOptions(AllBaseModels, options.BaseModels, savedOptions.SelectedBaseModels);
+ }
+
+ private void SetSelectableOptions(
+ ObservableCollection target,
+ IEnumerable values,
+ IReadOnlyCollection selectedValues
+ )
+ {
+ target.Clear();
+
+ var sortedValues = values.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
+ var selectAll = selectedValues.Count == 0;
+
+ foreach (var value in sortedValues)
+ {
+ var option = new BaseModelOptionViewModel
+ {
+ ModelType = value,
+ IsSelected = selectAll || selectedValues.Contains(value),
+ };
+ option.PropertyChanged += OnSelectableOptionChanged;
+ target.Add(option);
+ }
+
+ if (ReferenceEquals(target, AllModelTypes))
+ {
+ ApplyModelTypeFilter();
+ OnPropertyChanged(nameof(ModelTypeSelectionSummary));
+ OnPropertyChanged(nameof(HasModelTypeFilter));
+ OnPropertyChanged(nameof(SelectedModelTypeCount));
+ }
+ else if (ReferenceEquals(target, AllBaseModels))
+ {
+ ApplyBaseModelFilter();
+ OnPropertyChanged(nameof(BaseModelSelectionSummary));
+ OnPropertyChanged(nameof(HasBaseModelFilter));
+ OnPropertyChanged(nameof(SelectedBaseModelCount));
+ }
+ }
+
+ partial void OnModelTypeFilterChanged(string value) => ApplyModelTypeFilter();
+
+ partial void OnBaseModelFilterChanged(string value) => ApplyBaseModelFilter();
+
+ private void ApplyModelTypeFilter() =>
+ RefreshFilteredOptions(AllModelTypes, FilteredModelTypes, ModelTypeFilter);
+
+ private void ApplyBaseModelFilter() =>
+ RefreshFilteredOptions(AllBaseModels, FilteredBaseModels, BaseModelFilter);
+
+ private static void RefreshFilteredOptions(
+ ObservableCollection source,
+ ObservableCollection target,
+ string filter
+ )
+ {
+ var query = filter?.Trim() ?? string.Empty;
+ var matches = string.IsNullOrEmpty(query)
+ ? source
+ : source.Where(x => x.ModelType.Contains(query, StringComparison.OrdinalIgnoreCase));
+
+ target.Clear();
+ foreach (var item in matches)
+ {
+ target.Add(item);
+ }
+ }
+
+ private void RestoreSettings()
+ {
+ var options = settingsManager.Settings.CivArchiveBrowserOptions;
+
+ suppressSearch = true;
+ SearchQuery = options.Query;
+ TagQuery = options.Tags;
+ UsernameQuery = options.Username;
+ SelectedPlatform = AllPlatforms.First(x => x.Value == options.Platform);
+ SelectedSort = AllSorts.First(x => x.Value == options.Sort);
+ SelectedPeriod = AllPeriods.First(x => x.Value == options.Period);
+ SelectedRating = AllRatings.First(x => x.Value == options.Rating);
+ SelectedPlatformStatus = AllPlatformStatuses.First(x => x.Value == options.PlatformStatus);
+ SelectedKind = AllKinds.First(x => x.Value == options.Kind);
+ suppressSearch = false;
+ }
+
+ private CivArchiveSearchFilters BuildFilters(int page)
+ {
+ var selectedTypes = GetSelectedValues(AllModelTypes);
+ var selectedBaseModels = GetSelectedValues(AllBaseModels);
+
+ // Parse @user / #tag tokens inline from the search box and merge with
+ // explicit values from the More Filters flyout. Inline tokens win for username
+ // (only one allowed); tags are merged additively.
+ var (cleanedQuery, parsedTags, parsedUsername) = ParseSearchQuery(SearchQuery);
+ var combinedTags = string.Join(
+ ",",
+ new[] { TagQuery, parsedTags }.Where(s => !string.IsNullOrWhiteSpace(s))
+ );
+ var combinedUsername = !string.IsNullOrWhiteSpace(parsedUsername) ? parsedUsername : UsernameQuery;
+
+ return new CivArchiveSearchFilters
+ {
+ Query = cleanedQuery,
+ Tags = combinedTags,
+ Username = combinedUsername,
+ Platform = SelectedPlatform?.Value ?? CivArchivePlatformOption.All,
+ Sort = SelectedSort?.Value ?? CivArchiveSortOption.Top,
+ Period = SelectedPeriod?.Value ?? CivArchivePeriodOption.All,
+ Rating = SelectedRating?.Value ?? CivArchiveRatingOption.Safe,
+ PlatformStatus = SelectedPlatformStatus?.Value ?? CivArchivePlatformStatusOption.All,
+ Kind = SelectedKind?.Value ?? CivArchiveKindOption.All,
+ Types = selectedTypes.Count == AllModelTypes.Count ? [] : selectedTypes,
+ BaseModels = selectedBaseModels.Count == AllBaseModels.Count ? [] : selectedBaseModels,
+ Page = page,
+ };
+ }
+
+ ///
+ /// Pull @user and #tag tokens out of a free-form search string.
+ /// Returns the leftover query (model name search), comma-joined tag list, and the
+ /// parsed username (last-wins if multiple @ tokens are present).
+ ///
+ internal static (string query, string tags, string username) ParseSearchQuery(string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return (string.Empty, string.Empty, string.Empty);
+
+ var tokens = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var queryParts = new List();
+ var tags = new List();
+ string username = string.Empty;
+
+ foreach (var token in tokens)
+ {
+ if (token.Length > 1 && token[0] == '@')
+ username = token[1..]; // last @user wins
+ else if (token.Length > 1 && token[0] == '#')
+ tags.Add(token[1..]);
+ else
+ queryParts.Add(token);
+ }
+
+ return (string.Join(' ', queryParts), string.Join(',', tags), username);
+ }
+
+ private static List GetSelectedValues(IEnumerable options)
+ {
+ return options.Where(x => x.IsSelected).Select(x => x.ModelType).ToList();
+ }
+
+ private void SaveSettings()
+ {
+ if (!settingsManager.IsLibraryDirSet)
+ {
+ return;
+ }
+
+ settingsManager.Transaction(s =>
+ s.CivArchiveBrowserOptions = new CivArchiveBrowserOptions
+ {
+ Query = SearchQuery,
+ Tags = TagQuery,
+ Username = UsernameQuery,
+ Platform = SelectedPlatform?.Value ?? CivArchivePlatformOption.All,
+ Sort = SelectedSort?.Value ?? CivArchiveSortOption.Top,
+ Period = SelectedPeriod?.Value ?? CivArchivePeriodOption.All,
+ Rating = SelectedRating?.Value ?? CivArchiveRatingOption.Safe,
+ PlatformStatus = SelectedPlatformStatus?.Value ?? CivArchivePlatformStatusOption.All,
+ Kind = SelectedKind?.Value ?? CivArchiveKindOption.All,
+ SelectedModelTypes = GetSelectedValues(AllModelTypes),
+ SelectedBaseModels = GetSelectedValues(AllBaseModels),
+ }
+ );
+ }
+
+ private void OnSelectableOptionChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(BaseModelOptionViewModel.IsSelected))
+ {
+ return;
+ }
+
+ OnPropertyChanged(nameof(ModelTypeSelectionSummary));
+ OnPropertyChanged(nameof(BaseModelSelectionSummary));
+ OnPropertyChanged(nameof(HasModelTypeFilter));
+ OnPropertyChanged(nameof(HasBaseModelFilter));
+ OnPropertyChanged(nameof(SelectedModelTypeCount));
+ OnPropertyChanged(nameof(SelectedBaseModelCount));
+
+ RequestDebouncedSearch();
+ }
+
+ partial void OnSelectedPlatformChanged(NamedOption? value) =>
+ TriggerFilterSearch();
+
+ partial void OnSelectedSortChanged(NamedOption? value) => TriggerFilterSearch();
+
+ partial void OnSelectedPeriodChanged(NamedOption? value) => TriggerFilterSearch();
+
+ partial void OnSelectedRatingChanged(NamedOption? value) => TriggerFilterSearch();
+
+ partial void OnSelectedPlatformStatusChanged(NamedOption? value) =>
+ TriggerFilterSearch();
+
+ partial void OnSelectedKindChanged(NamedOption? value) => TriggerFilterSearch();
+
+ private void TriggerFilterSearch() => RequestDebouncedSearch();
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveDetailsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveDetailsPageViewModel.cs
new file mode 100644
index 000000000..d84c50186
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveDetailsPageViewModel.cs
@@ -0,0 +1,922 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Reactive.Disposables;
+using System.Reactive.Linq;
+using System.Threading;
+using AsyncAwaitBestPractices;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DynamicData.Binding;
+using FluentAvalonia.Core;
+using FluentAvalonia.UI.Controls;
+using Injectio.Attributes;
+using StabilityMatrix.Avalonia.Extensions;
+using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Models.Inference;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.Dialogs;
+using StabilityMatrix.Avalonia.Views;
+using StabilityMatrix.Core.Api;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Extensions;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Api;
+using StabilityMatrix.Core.Models.Api.CivArchive;
+using StabilityMatrix.Core.Models.Database;
+using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Models.Progress;
+using StabilityMatrix.Core.Processes;
+using StabilityMatrix.Core.Services;
+
+namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
+
+[View(typeof(CivArchiveDetailsPage))]
+[ManagedService]
+[RegisterTransient]
+public partial class CivArchiveDetailsPageViewModel(
+ ICivArchiveApiClient civArchiveApiClient,
+ INavigationService navigationService,
+ IServiceManager vmFactory,
+ IModelImportService modelImportService,
+ ISettingsManager settingsManager,
+ INotificationService notificationService,
+ IModelIndexService modelIndexService
+) : DisposableViewModelBase
+{
+ private static readonly string[] IgnoredFileNameFormatVars =
+ [
+ "seed",
+ "prompt",
+ "negative_prompt",
+ "model_hash",
+ "sampler",
+ "cfgscale",
+ "steps",
+ "width",
+ "height",
+ "project_type",
+ "project_name",
+ ];
+
+ public IEnumerable ModelFileNameFormatVars =>
+ FileNameFormatProvider
+ .GetSampleForModelBrowser()
+ .Substitutions.Where(kv => !IgnoredFileNameFormatVars.Contains(kv.Key))
+ .Select(kv => new FileNameFormatVar { Variable = $"{{{kv.Key}}}", Example = kv.Value.Invoke() });
+
+ [ObservableProperty]
+ public partial string RelativeUrl { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial CivArchiveModelDetails? Model { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsLoading { get; set; }
+
+ [ObservableProperty]
+ public partial string ErrorText { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial CivArchiveVersionReference? SelectedVersion { get; set; }
+
+ [ObservableProperty]
+ public partial string ModelDescriptionHtml { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial string VersionDescriptionHtml { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial bool HasDownloadUrl { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsInstalled { get; set; }
+
+ [ObservableProperty]
+ public partial string? InstalledLocation { get; set; }
+
+ [ObservableProperty]
+ [CustomValidation(typeof(CivArchiveDetailsPageViewModel), nameof(ValidateModelFileNameFormat))]
+ public partial string? ModelFileNameFormat { get; set; }
+
+ [ObservableProperty]
+ public partial string? ModelNameFormatSample { get; set; }
+
+ public ObservableCollection Images { get; } = [];
+ public ObservableCollection Files { get; } = [];
+ public ObservableCollection Mirrors { get; } = [];
+
+ private static readonly Dictionary<
+ string,
+ (SharedFolderType Folder, CivitModelType ModelType)
+ > ModelTypeMap = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Checkpoint"] = (SharedFolderType.StableDiffusion, CivitModelType.Checkpoint),
+ ["LORA"] = (SharedFolderType.Lora, CivitModelType.LORA),
+ ["DoRA"] = (SharedFolderType.Lora, CivitModelType.DoRA),
+ ["LoCon"] = (SharedFolderType.LyCORIS, CivitModelType.LoCon),
+ ["TextualInversion"] = (SharedFolderType.Embeddings, CivitModelType.TextualInversion),
+ ["Hypernetwork"] = (SharedFolderType.Hypernetwork, CivitModelType.Hypernetwork),
+ ["Controlnet"] = (SharedFolderType.ControlNet, CivitModelType.Controlnet),
+ ["VAE"] = (SharedFolderType.VAE, CivitModelType.VAE),
+ ["Upscaler"] = (SharedFolderType.ESRGAN, CivitModelType.Upscaler),
+ };
+
+ protected override async Task OnInitialLoadedAsync()
+ {
+ await base.OnInitialLoadedAsync();
+
+ AddDisposable(
+ settingsManager.RelayPropertyFor(
+ this,
+ vm => vm.ModelFileNameFormat,
+ settings => settings.CivitModelBrowserFileNamePattern,
+ true
+ )
+ );
+
+ AddDisposable(
+ this.WhenPropertyChanged(vm => vm.ModelFileNameFormat)
+ .Throttle(TimeSpan.FromMilliseconds(50))
+ .ObserveOn(SynchronizationContext.Current!)
+ .Subscribe(_ => UpdateNameFormatSample())
+ );
+
+ AddDisposable(
+ this.WhenPropertyChanged(vm => vm.Model)
+ .ObserveOn(SynchronizationContext.Current!)
+ .Subscribe(_ => UpdateNameFormatSample())
+ );
+
+ // Refresh the IsInstalled badge / Download button label when the user downloads
+ // (or deletes) this model β without forcing them to navigate away and back.
+ EventHandler indexChangedHandler = (_, _) =>
+ Dispatcher.UIThread.Post(() => UpdateInstalledStatus(Model?.Version));
+ EventManager.Instance.ModelIndexChanged += indexChangedHandler;
+ AddDisposable(
+ Disposable.Create(() => EventManager.Instance.ModelIndexChanged -= indexChangedHandler)
+ );
+ }
+
+ public override async Task OnLoadedAsync()
+ {
+ await base.OnLoadedAsync();
+
+ if (IsLoading || string.IsNullOrWhiteSpace(RelativeUrl))
+ {
+ return;
+ }
+
+ await LoadModelAsync();
+ }
+
+ public static ValidationResult ValidateModelFileNameFormat(string? format, ValidationContext context)
+ {
+ return FileNameFormatProvider.GetSampleForModelBrowser().Validate(format ?? string.Empty);
+ }
+
+ private void UpdateNameFormatSample()
+ {
+ var provider = BuildFormatProvider(Model?.Version, GetPrimaryFile(Model?.Version));
+ var format = ParseFormatOrDefault(ModelFileNameFormat, provider);
+
+ var sample = NormalizePathSegments(format.GetFileName());
+ ModelNameFormatSample = string.IsNullOrWhiteSpace(sample)
+ ? null
+ : "Example: " + sample + ".safetensors";
+ }
+
+ ///
+ /// Strip empty path segments left behind by null/empty substitutions, so a pattern
+ /// like {base_model}/{file_name} with an empty base_model collapses to
+ /// file_name instead of /file_name .
+ /// Also drops .. / . traversal segments so a pattern variable that
+ /// resolves to .. can't escape the destination folder.
+ ///
+ private static string NormalizePathSegments(string raw)
+ {
+ if (string.IsNullOrEmpty(raw) || (!raw.Contains('/') && !raw.Contains('\\')))
+ return raw;
+
+ var parts = raw.Split(
+ ['/', '\\'],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
+ )
+ .Where(p => p != ".." && p != ".");
+ return string.Join('/', parts);
+ }
+
+ ///
+ /// Parse a template against the provider, falling back to the default template if the input is empty,
+ /// references unknown variables, or fails to parse for any reason. Validate() must be called first
+ /// because Parse() throws KeyNotFoundException on unknown variables (e.g. mid-typing "{base}" before
+ /// the user finishes "{base_model}").
+ ///
+ private static FileNameFormat ParseFormatOrDefault(string? template, FileNameFormatProvider provider)
+ {
+ if (
+ !string.IsNullOrEmpty(template)
+ && provider.Validate(template) == ValidationResult.Success
+ && FileNameFormat.TryParse(template, provider, out var format)
+ )
+ {
+ return format;
+ }
+
+ return FileNameFormat.Parse(FileNameFormat.DefaultModelBrowserTemplate, provider);
+ }
+
+ private FileNameFormatProvider BuildFormatProvider(
+ CivArchiveModelVersion? version,
+ CivArchiveModelFile? primaryFile
+ )
+ {
+ if (Model is null)
+ {
+ return new FileNameFormatProvider();
+ }
+
+ // Build CivitModel-shaped stubs so FileNameFormatProvider can resolve {model_name}, {file_name}, etc.
+ var modelType = CivitModelType.Unknown;
+ if (Model.Type is not null && ModelTypeMap.TryGetValue(Model.Type, out var mapping))
+ {
+ modelType = mapping.ModelType;
+ }
+
+ var synthesizedFileName = string.IsNullOrWhiteSpace(version?.Name)
+ ? $"{Model.Name}.safetensors"
+ : $"{Model.Name}_{version.Name}.safetensors";
+
+ var civitModel = new CivitModel
+ {
+ Id = int.TryParse(Model.Id, out var modelId) ? modelId : 0,
+ Name = Model.Name,
+ Type = modelType,
+ Creator = new CivitCreator { Username = Model.CreatorUsername ?? Model.Username },
+ };
+
+ var civitVersion = version is null
+ ? null
+ : new CivitModelVersion
+ {
+ Id = int.TryParse(version.Id, out var versionId) ? versionId : 0,
+ Name = version.Name,
+ BaseModel = version.BaseModel,
+ };
+
+ var civitFile = new CivitFile
+ {
+ Id = int.TryParse(primaryFile?.Id, out var fileId) ? fileId : 0,
+ Name = !string.IsNullOrWhiteSpace(primaryFile?.Name) ? primaryFile.Name : synthesizedFileName,
+ };
+
+ return new FileNameFormatProvider
+ {
+ CivitModel = civitModel,
+ CivitModelVersion = civitVersion,
+ CivitFile = civitFile,
+ };
+ }
+
+ private async Task LoadModelAsync()
+ {
+ IsLoading = true;
+ ErrorText = string.Empty;
+
+ try
+ {
+ var response = await civArchiveApiClient.GetModelDetailsAsync(RelativeUrl);
+ Model = response.Model;
+
+ ModelDescriptionHtml = WrapHtml(response.Model.Description);
+ PopulateVersionData(response.Model.Version);
+ }
+ catch (Exception ex)
+ {
+ ErrorText = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private void PopulateVersionData(CivArchiveModelVersion? version)
+ {
+ VersionDescriptionHtml = WrapHtml(version?.Description);
+ HasDownloadUrl = GetDownloadUris(version).Count > 0;
+
+ Images.Clear();
+ foreach (var image in version?.Images.Where(IsUsableImage) ?? [])
+ {
+ Images.Add(image);
+ }
+
+ Files.Clear();
+ foreach (var file in version?.Files ?? [])
+ {
+ Files.Add(file);
+ }
+
+ Mirrors.Clear();
+ foreach (var mirror in version?.Mirrors ?? [])
+ {
+ Mirrors.Add(mirror);
+ }
+
+ UpdateInstalledStatus(version);
+ }
+
+ private void UpdateInstalledStatus(CivArchiveModelVersion? version)
+ {
+ // First try URL match β works for every platform, including ones where file hashes are missing.
+ var installedUrls = modelIndexService.ModelIndexCivArchiveUrls;
+ if (!string.IsNullOrWhiteSpace(RelativeUrl) && installedUrls.Contains(RelativeUrl))
+ {
+ IsInstalled = true;
+ InstalledLocation = LookupInstalledLocationByUrl(RelativeUrl);
+ return;
+ }
+
+ // Fallback to SHA256 match β catches CivitAI mirrors with full file hashes,
+ // including models downloaded via SM before SourceUrl tracking existed.
+ var hashes = version
+ ?.Files.Select(f => f.Sha256)
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Cast()
+ .ToList();
+
+ if (hashes is null || hashes.Count == 0)
+ {
+ IsInstalled = false;
+ InstalledLocation = null;
+ return;
+ }
+
+ var installedHashes = modelIndexService.ModelIndexSha256Hashes;
+ var matchedHash = hashes.FirstOrDefault(h => installedHashes.Contains(h));
+
+ if (matchedHash is null)
+ {
+ IsInstalled = false;
+ InstalledLocation = null;
+ return;
+ }
+
+ IsInstalled = true;
+ _ = LoadInstalledLocationAsync(matchedHash);
+ }
+
+ private string? LookupInstalledLocationByUrl(string sourceUrl)
+ {
+ return modelIndexService
+ .ModelIndex.Values.SelectMany(x => x)
+ .FirstOrDefault(m =>
+ m.HasCivArchiveMetadata
+ && string.Equals(
+ m.ConnectedModelInfo.SourceUrl,
+ sourceUrl,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ ?.RelativePath;
+ }
+
+ private async Task LoadInstalledLocationAsync(string sha256)
+ {
+ try
+ {
+ var matches = await modelIndexService.FindBySha256Async(sha256);
+ var first = matches?.FirstOrDefault();
+ InstalledLocation = first?.RelativePath;
+ }
+ catch
+ {
+ InstalledLocation = null;
+ }
+ }
+
+ private static string WrapHtml(string? html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return string.Empty;
+ }
+
+ return $"""{html}""";
+ }
+
+ [RelayCommand]
+ private async Task ShowImageDialog(CivArchiveModelImage? image)
+ {
+ if (image?.Url is null)
+ {
+ return;
+ }
+
+ var currentIndex = Images.IndexOf(image);
+ var imageSource = await PrepareImageSourceAsync(image.Url);
+ if (imageSource is null)
+ return;
+
+ var vm = vmFactory.Get();
+ vm.ImageSource = imageSource;
+
+ using var onNav = Observable
+ .FromEventPattern(
+ vm,
+ nameof(ImageViewerViewModel.NavigationRequested)
+ )
+ .ObserveOn(SynchronizationContext.Current!)
+ .Subscribe(ctx =>
+ {
+ Dispatcher
+ .UIThread.InvokeAsync(async () =>
+ {
+ var sender = (ImageViewerViewModel)ctx.Sender!;
+ var newIndex = currentIndex + (ctx.EventArgs.IsNext ? 1 : -1);
+
+ if (newIndex >= 0 && newIndex < Images.Count)
+ {
+ var newImage = Images[newIndex];
+ if (newImage.Url is null)
+ {
+ return;
+ }
+
+ var newSource = await PrepareImageSourceAsync(newImage.Url);
+ if (newSource is null)
+ return;
+
+ sender.ImageSource = newSource;
+ currentIndex = newIndex;
+ }
+ })
+ .SafeFireAndForget();
+ });
+
+ await vm.GetDialog().ShowAsync();
+ }
+
+ ///
+ /// Build an ready for the image viewer to render.
+ /// The viewer's template selector keys off ImageSource.TemplateKey ; if that's
+ /// Default , the selector renders the literal "Unsupported Format" text. The
+ /// URL-construction path leaves TemplateKey as Default until a Task-based binding
+ /// resolves it, which races with the viewer's first paint on extensionless CivArchive
+ /// CDN URLs (e.g. img.genur.art/sig/.../base64 ). Use the bitmap-only constructor
+ /// instead β it sets TemplateKey to Image synchronously, which the AdvancedImageBox
+ /// can render whether the bytes were JPEG, PNG, or WebP.
+ ///
+ private static async Task PrepareImageSourceAsync(string url)
+ {
+ try
+ {
+ var loader = new ImageSource(new Uri(url));
+ var bitmap = await loader.GetBitmapAsync();
+ return bitmap is not null ? new ImageSource(bitmap) { RemoteUrl = new Uri(url) } : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ [RelayCommand]
+ private async Task SelectVersion(CivArchiveVersionReference? versionRef)
+ {
+ if (versionRef is null || string.IsNullOrWhiteSpace(versionRef.Href) || IsLoading)
+ {
+ return;
+ }
+
+ SelectedVersion = versionRef;
+ RelativeUrl = versionRef.Href;
+ await LoadModelAsync();
+ }
+
+ [RelayCommand]
+ private void GoBack()
+ {
+ if (!navigationService.GoBack())
+ {
+ navigationService.NavigateTo();
+ }
+ }
+
+ [RelayCommand]
+ private void OpenOnCivArchive()
+ {
+ ProcessRunner.OpenUrl(civArchiveApiClient.GetAbsoluteUri(RelativeUrl).ToString());
+ }
+
+ [RelayCommand]
+ private async Task DownloadModel()
+ {
+ var version = Model?.Version;
+ if (version is null)
+ return;
+
+ var primaryFile = GetPrimaryFile(version);
+ await ExecuteDownloadAsync(version, primaryFile, GetDownloadUris(version), sourceLabel: null);
+ }
+
+ [RelayCommand]
+ private async Task DeleteModel()
+ {
+ var localFiles = FindLocallyInstalledFiles();
+ if (localFiles.Count == 0)
+ return;
+
+ var pathsToDelete = new List();
+ foreach (var localFile in localFiles)
+ {
+ var checkpointPath = new FilePath(localFile.GetFullPath(settingsManager.ModelsDirectory));
+ if (File.Exists(checkpointPath))
+ pathsToDelete.Add(checkpointPath);
+
+ var previewPath = localFile.GetPreviewImageFullPath(settingsManager.ModelsDirectory);
+ if (!string.IsNullOrEmpty(previewPath) && File.Exists(previewPath))
+ pathsToDelete.Add(previewPath);
+
+ var cmInfoPath = checkpointPath
+ .ToString()
+ .Replace(checkpointPath.Extension, ConnectedModelInfo.FileExtension);
+ if (File.Exists(cmInfoPath))
+ pathsToDelete.Add(cmInfoPath);
+ }
+
+ if (pathsToDelete.Count == 0)
+ return;
+
+ var confirmDeleteVm = vmFactory.Get();
+ confirmDeleteVm.PathsToDelete = pathsToDelete;
+
+ var dialog = confirmDeleteVm.GetDialog();
+ var result = await dialog.ShowAsync();
+
+ if (result != ContentDialogResult.Primary)
+ return;
+
+ try
+ {
+ await confirmDeleteVm.ExecuteCurrentDeleteOperationAsync(failFast: true);
+ }
+ catch (Exception ex)
+ {
+ notificationService.Show("Delete failed", ex.Message);
+ }
+ finally
+ {
+ await modelIndexService.RefreshIndex();
+ // RefreshIndex fires ModelIndexChanged β our handler updates IsInstalled
+ // and the UI flips back to "Download" automatically.
+ }
+ }
+
+ ///
+ /// Find every locally installed file matching this model β try the SourceUrl first,
+ /// fall back to SHA256 hash matches for legacy downloads without SourceUrl.
+ ///
+ private List FindLocallyInstalledFiles()
+ {
+ var matches = new List();
+ var allLocal = modelIndexService.ModelIndex.Values.SelectMany(x => x).ToList();
+
+ if (!string.IsNullOrWhiteSpace(RelativeUrl))
+ {
+ matches.AddRange(
+ allLocal.Where(m =>
+ m.HasCivArchiveMetadata
+ && string.Equals(
+ m.ConnectedModelInfo.SourceUrl,
+ RelativeUrl,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ );
+ }
+
+ if (matches.Count == 0 && Model?.Version is { } version)
+ {
+ var hashes = version
+ .Files.Select(f => f.Sha256)
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Cast()
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (hashes.Count > 0)
+ {
+ matches.AddRange(
+ allLocal.Where(m =>
+ !string.IsNullOrWhiteSpace(m.HashSha256) && hashes.Contains(m.HashSha256)
+ )
+ );
+ }
+ }
+
+ return matches;
+ }
+
+ [RelayCommand]
+ private async Task DownloadFile(CivArchiveModelFile? file)
+ {
+ var version = Model?.Version;
+ if (version is null || file is null)
+ return;
+
+ await ExecuteDownloadAsync(version, file, GetDownloadUrisForFile(file), sourceLabel: null);
+ }
+
+ [RelayCommand]
+ private async Task DownloadFromMirror(CivArchiveFileMirror? mirror)
+ {
+ if (mirror is null || string.IsNullOrWhiteSpace(mirror.Url))
+ return;
+
+ // Gated/paid mirrors require auth or payment we can't handle in-app β open externally.
+ if (mirror.IsGated || mirror.IsPaid)
+ {
+ ProcessRunner.OpenUrl(mirror.Url);
+ return;
+ }
+
+ var version = Model?.Version;
+ if (version is null)
+ return;
+
+ // Find the parent file so we can attach hash + cm-info correctly.
+ var parentFile = version.Files.FirstOrDefault(f => f.Mirrors.Contains(mirror));
+
+ await ExecuteDownloadAsync(
+ version,
+ parentFile,
+ [civArchiveApiClient.GetAbsoluteUri(mirror.Url)],
+ sourceLabel: mirror.Source
+ );
+ }
+
+ private async Task ExecuteDownloadAsync(
+ CivArchiveModelVersion version,
+ CivArchiveModelFile? file,
+ IReadOnlyList downloadUris,
+ string? sourceLabel
+ )
+ {
+ if (Model is null)
+ return;
+
+ if (downloadUris.Count == 0)
+ {
+ notificationService.Show(
+ "No download available",
+ "This file has no usable download URL β every mirror was either missing or gated/paid."
+ );
+ return;
+ }
+
+ if (!settingsManager.IsLibraryDirSet)
+ {
+ notificationService.Show("Download Failed", "Please set a library directory in settings first.");
+ return;
+ }
+
+ var destinationDir = GetDefaultDownloadFolder();
+ var fileName = BuildDownloadFileName(version, file);
+
+ Uri? previewImageUri = null;
+ string? previewImageExtension = null;
+ var firstImage = version.Images.FirstOrDefault(IsUsableImage);
+ if (firstImage?.Url is not null)
+ {
+ previewImageUri = new Uri(firstImage.Url);
+ previewImageExtension = ResolvePreviewImageExtension(previewImageUri);
+ }
+
+ var connectedModelInfo = BuildConnectedModelInfo(Model, version, RelativeUrl);
+ // Override hash so the cm-info matches the specific file being downloaded
+ // (BuildConnectedModelInfo defaults to the primary file's hash).
+ if (!string.IsNullOrWhiteSpace(file?.Sha256))
+ {
+ connectedModelInfo.Hashes = new CivitFileHashes { SHA256 = file.Sha256 };
+ }
+
+ await modelImportService.DoCustomImport(
+ downloadUris,
+ fileName,
+ destinationDir,
+ previewImageUri,
+ previewImageFileExtension: previewImageExtension,
+ connectedModelInfo: connectedModelInfo,
+ configureDownload: download =>
+ {
+ if (!string.IsNullOrWhiteSpace(file?.Sha256))
+ {
+ download.ExpectedHashSha256 = file.Sha256;
+ }
+
+ // The CivitAI flow uses CivitPostDownloadContextAction to refresh the
+ // model index post-download; we don't have an analogous context action
+ // (we rely on cm-info instead of Blake3 hash), so subscribe directly to
+ // ProgressStateChanged. Refreshing the index fires ModelIndexChanged,
+ // which our OnInitialLoadedAsync subscription uses to flip the Installed
+ // badge / "Download again" label live.
+ download.ProgressStateChanged += (_, state) =>
+ {
+ if (state == ProgressState.Success)
+ {
+ modelIndexService.BackgroundRefreshIndex();
+ }
+ };
+ }
+ );
+
+ var finalPath = destinationDir.JoinFile(fileName);
+ var sourceText = string.IsNullOrEmpty(sourceLabel) ? string.Empty : $" from {sourceLabel}";
+ notificationService.Show(
+ "Download Started",
+ $"{finalPath.Name}{sourceText} will be saved to {finalPath.Directory}"
+ );
+ }
+
+ ///
+ /// CivArchive aggregates images from many platforms; some URLs don't end in a recognizable
+ /// extension (e.g. CivitAI's "/width=512/img" style paths or extension-less CDN URLs). Try
+ /// Path.GetExtension first, then scan the raw URL for a known image extension, then fall back
+ /// to ".jpeg" so the import never fails at the preview-image step.
+ ///
+ private static string ResolvePreviewImageExtension(Uri previewImageUri)
+ {
+ var fromPath = Path.GetExtension(previewImageUri.LocalPath);
+ if (!string.IsNullOrWhiteSpace(fromPath))
+ return fromPath;
+
+ ReadOnlySpan known = [".jpeg", ".jpg", ".png", ".webp", ".gif", ".avif"];
+ var raw = previewImageUri.ToString();
+ foreach (var ext in known)
+ {
+ if (raw.Contains(ext, StringComparison.OrdinalIgnoreCase))
+ return ext;
+ }
+
+ return ".jpeg";
+ }
+
+ private IReadOnlyList GetDownloadUrisForFile(CivArchiveModelFile file)
+ {
+ var urlCandidates = new List { file.DownloadUrl };
+ if (file.Mirrors is not null)
+ {
+ urlCandidates.AddRange(file.Mirrors.Where(m => !m.IsGated && !m.IsPaid).Select(m => m.Url));
+ }
+
+ return urlCandidates
+ .Where(url => !string.IsNullOrWhiteSpace(url))
+ .Select(url => civArchiveApiClient.GetAbsoluteUri(url!))
+ .Distinct()
+ .ToList();
+ }
+
+ private IReadOnlyList GetDownloadUris(CivArchiveModelVersion? version)
+ {
+ if (version is null)
+ {
+ return [];
+ }
+
+ var primaryFile = GetPrimaryFile(version);
+ var urlCandidates = new List { version.DownloadUrl, primaryFile?.DownloadUrl };
+
+ if (primaryFile?.Mirrors is not null)
+ {
+ urlCandidates.AddRange(primaryFile.Mirrors.Select(mirror => mirror.Url));
+ }
+
+ return urlCandidates
+ .Where(url => !string.IsNullOrWhiteSpace(url))
+ .Select(url => civArchiveApiClient.GetAbsoluteUri(url!))
+ .Distinct()
+ .ToList();
+ }
+
+ private static CivArchiveModelFile? GetPrimaryFile(CivArchiveModelVersion? version)
+ {
+ if (version is null)
+ {
+ return null;
+ }
+
+ return version.Files.FirstOrDefault(f => f.IsPrimary) ?? version.Files.FirstOrDefault();
+ }
+
+ private string BuildDownloadFileName(CivArchiveModelVersion version, CivArchiveModelFile? primaryFile)
+ {
+ var extension = !string.IsNullOrWhiteSpace(primaryFile?.Name)
+ ? Path.GetExtension(primaryFile.Name)
+ : ".safetensors";
+ if (string.IsNullOrEmpty(extension))
+ {
+ extension = ".safetensors";
+ }
+
+ var provider = BuildFormatProvider(version, primaryFile);
+ var format = ParseFormatOrDefault(ModelFileNameFormat, provider);
+
+ // Normalize so a leading "/" from an empty {base_model} doesn't make Path.Combine
+ // treat the name as rooted and drop the destination folder.
+ var stem = NormalizePathSegments(format.GetFileName());
+
+ if (string.IsNullOrWhiteSpace(stem))
+ {
+ // Pattern resolved to empty (e.g. only {file_name} on a non-CivitAI mirror with no primary file).
+ // Fall back to a sensible synthesized name.
+ stem = string.IsNullOrWhiteSpace(version.Name)
+ ? Model?.Name ?? "model"
+ : $"{Model?.Name ?? "model"}_{version.Name}";
+ }
+
+ return stem + extension;
+ }
+
+ private DirectoryPath GetDefaultDownloadFolder()
+ {
+ var modelType = Model?.Type;
+ if (modelType is not null && ModelTypeMap.TryGetValue(modelType, out var mapping))
+ {
+ return new DirectoryPath(settingsManager.ModelsDirectory, mapping.Folder.GetStringValue());
+ }
+
+ return new DirectoryPath(settingsManager.ModelsDirectory);
+ }
+
+ private static ConnectedModelInfo BuildConnectedModelInfo(
+ CivArchiveModelDetails model,
+ CivArchiveModelVersion version,
+ string sourceUrl
+ )
+ {
+ var civitModelType = CivitModelType.Unknown;
+ if (model.Type is not null && ModelTypeMap.TryGetValue(model.Type, out var mapping))
+ {
+ civitModelType = mapping.ModelType;
+ }
+
+ var primaryFile = version.Files.FirstOrDefault(f => f.IsPrimary) ?? version.Files.FirstOrDefault();
+
+ return new ConnectedModelInfo
+ {
+ ModelName = model.Name,
+ ModelDescription = model.Description ?? string.Empty,
+ Nsfw = model.IsNsfw,
+ Tags = model.Tags.ToArray(),
+ ModelType = civitModelType,
+ VersionName = version.Name,
+ VersionDescription = version.Description,
+ BaseModel = version.BaseModel,
+ ImportedAt = DateTimeOffset.UtcNow,
+ Hashes = new CivitFileHashes { SHA256 = primaryFile?.Sha256 },
+ TrainedWords = version.Trigger.ToArray(),
+ ThumbnailImageUrl = version.Images.FirstOrDefault(IsUsableImage)?.Url,
+ Source = ConnectedModelSource.CivArchive,
+ SourceUrl = sourceUrl,
+ Stats = new CivitModelStats
+ {
+ DownloadCount = (int)model.DownloadCount,
+ FavoriteCount = (int)model.FavoriteCount,
+ CommentCount = (int)model.CommentCount,
+ RatingCount = (int)model.RatingCount,
+ Rating = model.Rating,
+ },
+ };
+ }
+
+ private static bool IsUsableImage(CivArchiveModelImage image)
+ {
+ return !string.IsNullOrWhiteSpace(image.Url)
+ && (
+ string.IsNullOrWhiteSpace(image.Type)
+ || string.Equals(image.Type, "image", StringComparison.OrdinalIgnoreCase)
+ );
+ }
+
+ [RelayCommand]
+ private void OpenVersionMirror(CivArchiveVersionMirror? mirror)
+ {
+ if (!string.IsNullOrWhiteSpace(mirror?.PlatformUrl))
+ {
+ ProcessRunner.OpenUrl(mirror.PlatformUrl);
+ }
+ }
+
+ [RelayCommand]
+ private async Task CopySha256(CivArchiveModelFile? file)
+ {
+ if (!string.IsNullOrWhiteSpace(file?.Sha256) && App.Clipboard is not null)
+ {
+ await App.Clipboard.SetTextAsync(file.Sha256);
+ }
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs
index bdcd4bcf8..bf5460efa 100644
--- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs
@@ -11,6 +11,7 @@
using CommunityToolkit.Mvvm.Input;
using DynamicData;
using DynamicData.Binding;
+using FluentAvalonia.UI.Media.Animation;
using Injectio.Attributes;
using NLog;
using Refit;
@@ -20,6 +21,7 @@
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
+using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
@@ -48,6 +50,7 @@ public sealed partial class CivitAiBrowserViewModel : TabViewModelBase, IInfinit
private readonly INotificationService notificationService;
private readonly ICivitBaseModelTypeService baseModelTypeService;
private readonly INavigationService navigationService;
+ private readonly INavigationService settingsNavigationService;
private bool dontSearch = false;
private readonly SourceCache, int> modelCache = new(static ov => ov.Value.Id);
@@ -147,7 +150,8 @@ public CivitAiBrowserViewModel(
IConnectedServiceManager connectedServiceManager,
INotificationService notificationService,
ICivitBaseModelTypeService baseModelTypeService,
- INavigationService navigationService
+ INavigationService navigationService,
+ INavigationService settingsNavigationService
)
{
this.civitApi = civitApi;
@@ -158,6 +162,7 @@ INavigationService navigationService
this.notificationService = notificationService;
this.baseModelTypeService = baseModelTypeService;
this.navigationService = navigationService;
+ this.settingsNavigationService = settingsNavigationService;
EventManager.Instance.NavigateAndFindCivitModelRequested += OnNavigateAndFindCivitModelRequested;
@@ -453,7 +458,41 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc
else
{
modelsResponse = await civitApi.GetModels(request);
- models = modelsResponse.Items;
+ if (modelsResponse.Items != null)
+ {
+ models.AddRange(modelsResponse.Items);
+ }
+ }
+
+ // CivitAI's list endpoint (/api/v1/models?ids=...) sometimes returns zero items
+ // for models that the single-model endpoint (/api/v1/models/{id}) can find just
+ // fine β another server-side cache desync. For each requested ID that didn't
+ // come back, fall back to a per-ID lookup (which itself goes through the tRPC
+ // fallback in CivitCompatApiManager when needed).
+ var returnedIds = models.Select(m => m.Id).ToHashSet();
+ foreach (var idStr in ids)
+ {
+ if (!int.TryParse(idStr.Trim(), out var idValue) || returnedIds.Contains(idValue))
+ continue;
+
+ try
+ {
+ var single = await civitApi.GetModelById(idValue);
+ // GetModelById can return a default-id object on 404 with some implementations;
+ // only accept it if it actually looks valid.
+ if (single.Id == idValue)
+ {
+ models.Add(single);
+ Logger.Info(
+ "Recovered model {Id} via per-ID fallback after list endpoint missed it",
+ idValue
+ );
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn(ex, "Per-ID fallback failed for model {Id}; skipping", idValue);
+ }
}
}
else
@@ -553,14 +592,32 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc
if (cacheNew)
{
+ // ID-targeted searches ($#1234 / civitai.com URLs / Installed/Favorites sorts)
+ // explicitly bypass the type+base-model filters when building the request, so the
+ // post-response sanity check would otherwise reject perfectly valid results
+ // (e.g. searching $#someLoraId while SelectedModelType=Checkpoint).
+ var isIdSearch = !string.IsNullOrEmpty(request.CommaSeparatedModelIds);
+
+ // "No filter" is sent when either zero or all base models are selected β mirror that
+ // when checking the response matches the current filter state.
+ var isNoBaseModelFilter =
+ SelectedBaseModels.Count == 0 || SelectedBaseModels.Count == AllBaseModels.Count;
var doesBaseModelTypeMatch =
- SelectedBaseModels.Count == 0
- ? request.BaseModels == null || request.BaseModels.Length == 0
- : SelectedBaseModels.SequenceEqual(request.BaseModels ?? []);
+ isIdSearch
+ || (
+ isNoBaseModelFilter
+ ? request.BaseModels == null || request.BaseModels.Length == 0
+ : SelectedBaseModels
+ .OrderBy(x => x)
+ .SequenceEqual((request.BaseModels ?? []).OrderBy(x => x))
+ );
var doesModelTypeMatch =
- SelectedModelType == CivitModelType.All
- ? request.Types == null || request.Types.Length == 0
- : SelectedModelType == request.Types?.FirstOrDefault();
+ isIdSearch
+ || (
+ SelectedModelType == CivitModelType.All
+ ? request.Types == null || request.Types.Length == 0
+ : SelectedModelType == request.Types?.FirstOrDefault()
+ );
if (doesBaseModelTypeMatch && doesModelTypeMatch)
{
@@ -839,9 +896,60 @@ private void ClearOrSelectAllBaseModels()
}
[RelayCommand]
- private void ShowVersionDialog(CivitModel model)
+ private async Task NavigateToBaseModelSettings()
+ {
+ navigationService.NavigateTo(new SuppressNavigationTransitionInfo());
+ await Task.Delay(100);
+ settingsNavigationService.NavigateTo(new SuppressNavigationTransitionInfo());
+ }
+
+ [RelayCommand]
+ private async Task ShowVersionDialog(CivitModel model)
{
var versions = model.ModelVersions;
+
+ // The CivitAI public REST API sometimes returns models with an empty modelVersions list
+ // even when versions exist on the website (server-side cache desync). Re-fetch via
+ // GetModelById β CivitCompatApiManager will transparently fall back to the tRPC endpoint
+ // to recover the missing versions when the REST response is empty.
+ if (versions is null || versions.Count == 0)
+ {
+ // Surface a loading state on the card the user clicked so they get feedback instead
+ // of staring at a frozen-looking UI for the ~1-2s round-trip.
+ var card = ModelCards.FirstOrDefault(c => c.CivitModel.Id == model.Id);
+ var previousIsLoading = card?.IsLoading ?? false;
+ var previousText = card?.Text;
+ if (card is not null)
+ {
+ card.IsLoading = true;
+ card.Text = "Loading...";
+ }
+
+ try
+ {
+ var refreshed = await civitApi.GetModelById(model.Id);
+ if (refreshed.ModelVersions is { Count: > 0 })
+ {
+ // Mutate in place so subsequent clicks on the same card don't re-fetch β
+ // model is the live CivitModel that the card holds via CommandParameter.
+ model.ModelVersions = refreshed.ModelVersions;
+ versions = refreshed.ModelVersions;
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.Warn(e, "Failed to refresh CivitModel {Id} when versions list was empty", model.Id);
+ }
+ finally
+ {
+ if (card is not null)
+ {
+ card.IsLoading = previousIsLoading;
+ card.Text = previousText;
+ }
+ }
+ }
+
if (versions is null || versions.Count == 0)
{
notificationService.Show(
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs
index e8143d9cc..1f9152ac4 100644
--- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs
@@ -65,6 +65,14 @@ IModelImportService modelImportService
[NotifyPropertyChangedFor(nameof(CanGoNext), nameof(CanGoPrevious))]
public required partial int CurrentIndex { get; set; }
+ ///
+ /// True when there's at least one preview image to render. Drives layout β when false,
+ /// the page collapses the carousel row so the description fills the space (e.g. when a
+ /// model was recovered via the tRPC fallback, which doesn't return per-version images).
+ ///
+ [ObservableProperty]
+ public partial bool HasImages { get; set; }
+
private List ignoredFileNameFormatVars =
[
"seed",
@@ -327,6 +335,18 @@ protected override async Task OnInitialLoadedAsync()
.Subscribe()
);
+ // Mirror the same filter chain to drive HasImages β used by the view to collapse
+ // the carousel row when nothing's there to show.
+ AddDisposable(
+ imageCache
+ .Connect()
+ .Filter(showNsfwPredicate)
+ .Filter(img => img.Type == "image")
+ .ToCollection()
+ .ObserveOn(SynchronizationContext.Current!)
+ .Subscribe(c => HasImages = c.Count > 0)
+ );
+
var includeTrainingDataPredicate = Observable
.FromEventPattern(this, nameof(PropertyChanged))
.Where(x => x.EventArgs.PropertyName is nameof(ShowTrainingData))
@@ -415,7 +435,8 @@ out var preference
settingsManager.ModelsDirectory,
viewModel.CivitFile.Type,
CivitModel.Type,
- CivitModel.BaseModelType
+ CivitModel.BaseModelType,
+ viewModel.CivitFile.Name
);
effectiveLocationKeyForPreference =
viewModel.InstallLocations.FirstOrDefault(loc =>
@@ -492,7 +513,12 @@ await modelImportService.DoImport(
SelectedVersion?.ModelVersion,
viewModel.CivitFile,
fileNameOverride,
- inferenceDefaults: IsInferenceDefaultsEnabled ? SamplerCardViewModel : null
+ inferenceDefaults: IsInferenceDefaultsEnabled ? SamplerCardViewModel : null,
+ onImportComplete: () =>
+ TryMoveDownloadedCheckpointToDiffusionModelsIfNeededAsync(
+ viewModel.CivitFile,
+ finalDestinationDir
+ )
);
notificationService.Show(
@@ -538,7 +564,8 @@ private async Task ShowBulkDownloadDialogAsync()
new DirectoryPath(settingsManager.ModelsDirectory),
file.FileViewModel.CivitFile.Type,
CivitModel.Type,
- CivitModel.BaseModelType
+ CivitModel.BaseModelType,
+ file.FileViewModel.CivitFile.Name
);
var folderName = Path.GetInvalidFileNameChars()
@@ -558,7 +585,12 @@ await modelImportService.DoImport(
destinationDir,
file.ModelVersion,
file.FileViewModel.CivitFile,
- fileNameOverride
+ fileNameOverride,
+ onImportComplete: () =>
+ TryMoveDownloadedCheckpointToDiffusionModelsIfNeededAsync(
+ file.FileViewModel.CivitFile,
+ destinationDir
+ )
);
}
@@ -865,7 +897,8 @@ private ObservableCollection LoadInstallLocations(CivitFile selectedFile
rootModelsDirectory,
selectedFile.Type,
CivitModel.Type,
- CivitModel.BaseModelType
+ CivitModel.BaseModelType,
+ selectedFile.Name
);
if (!downloadDirectory.ToString().EndsWith("Unknown"))
@@ -886,9 +919,15 @@ var directory in downloadDirectory.EnumerateDirectories(
}
}
- if (downloadDirectory.ToString().EndsWith(SharedFolderType.DiffusionModels.GetStringValue()))
+ var isGguf = Path.GetExtension(selectedFile.Name).Equals(".gguf", StringComparison.OrdinalIgnoreCase);
+
+ if (
+ downloadDirectory.ToString().EndsWith(SharedFolderType.DiffusionModels.GetStringValue())
+ && !isGguf
+ )
{
// also add StableDiffusion in case we have an AIO version
+ // (not for GGUFs, which are always UNet-only)
var stableDiffusionDirectory = rootModelsDirectory.JoinDir(
SharedFolderType.StableDiffusion.GetStringValue()
);
@@ -907,7 +946,8 @@ private static DirectoryPath GetSharedFolderPath(
DirectoryPath rootModelsDirectory,
CivitFileType? fileType,
CivitModelType modelType,
- string? baseModelType
+ string? baseModelType,
+ string? fileName = null
)
{
if (fileType is CivitFileType.VAE)
@@ -930,9 +970,217 @@ modelType is CivitModelType.Checkpoint
return rootModelsDirectory.JoinDir(SharedFolderType.DiffusionModels.GetStringValue());
}
+ // GGUF checkpoints are always UNet-only, route directly to DiffusionModels
+ if (
+ modelType is CivitModelType.Checkpoint
+ && fileName is not null
+ && Path.GetExtension(fileName).Equals(".gguf", StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ return rootModelsDirectory.JoinDir(SharedFolderType.DiffusionModels.GetStringValue());
+ }
+
return rootModelsDirectory.JoinDir(modelType.ConvertTo().GetStringValue());
}
+ private async Task TryMoveDownloadedCheckpointToDiffusionModelsIfNeededAsync(
+ CivitFile civitFile,
+ DirectoryPath requestedDestinationDir
+ )
+ {
+ if (
+ civitFile.Type is not (CivitFileType.Model or CivitFileType.PrunedModel)
+ || CivitModel.Type is not CivitModelType.Checkpoint
+ )
+ {
+ return;
+ }
+
+ if (!settingsManager.IsLibraryDirSet)
+ {
+ return;
+ }
+
+ if (!Path.GetExtension(civitFile.Name).Equals(".safetensors", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(civitFile.Hashes.BLAKE3))
+ {
+ return;
+ }
+
+ try
+ {
+ await modelIndexService.RefreshIndex();
+
+ var matchingModels = (await modelIndexService.FindByHashAsync(civitFile.Hashes.BLAKE3)).ToList();
+ if (matchingModels.Count == 0)
+ {
+ return;
+ }
+
+ var modelsRoot = new DirectoryPath(settingsManager.ModelsDirectory);
+ if (!IsPathWithinDirectory(requestedDestinationDir, modelsRoot))
+ {
+ return;
+ }
+
+ var sourceModel = PickMostLikelyDownloadedModel(
+ matchingModels,
+ modelsRoot,
+ requestedDestinationDir
+ );
+ if (sourceModel is null)
+ {
+ return;
+ }
+
+ var sourceModelPath = new FilePath(sourceModel.GetFullPath(modelsRoot));
+ if (!sourceModelPath.Exists)
+ {
+ return;
+ }
+
+ var checkpointKind = await SafetensorClassifier.ClassifyAsync(sourceModelPath);
+ if (checkpointKind is not SafetensorCheckpointKind.UnetOnly)
+ {
+ return;
+ }
+
+ var stableDiffusionRoot = modelsRoot.JoinDir(SharedFolderType.StableDiffusion.GetStringValue());
+ var diffusionModelsRoot = modelsRoot.JoinDir(SharedFolderType.DiffusionModels.GetStringValue());
+
+ if (!IsPathWithinDirectory(sourceModelPath, stableDiffusionRoot))
+ {
+ return;
+ }
+
+ var sourceDirectory = sourceModelPath.Directory;
+ if (sourceDirectory is null)
+ {
+ return;
+ }
+
+ var relativeSubDir = Path.GetRelativePath(stableDiffusionRoot, sourceDirectory);
+ var destinationDirectory =
+ relativeSubDir == "."
+ ? diffusionModelsRoot
+ : new DirectoryPath(Path.Combine(diffusionModelsRoot, relativeSubDir));
+
+ destinationDirectory.Create();
+
+ var originalModelName = sourceModelPath.Name;
+ var destinationModelPath = destinationDirectory.JoinFile(sourceModelPath.Name);
+ var movedModelPath = await sourceModelPath.MoveToWithIncrementAsync(destinationModelPath);
+ var wasRenamedForCollision = !movedModelPath.Name.Equals(
+ originalModelName,
+ StringComparison.OrdinalIgnoreCase
+ );
+
+ var cmInfoPath = sourceDirectory.JoinFile(
+ $"{Path.GetFileNameWithoutExtension(originalModelName)}{ConnectedModelInfo.FileExtension}"
+ );
+ if (cmInfoPath.Exists)
+ {
+ await FileTransfers.MoveFileAsync(
+ cmInfoPath,
+ destinationDirectory.JoinFile(
+ $"{movedModelPath.NameWithoutExtension}{ConnectedModelInfo.FileExtension}"
+ ),
+ overwrite: true
+ );
+ }
+
+ foreach (
+ var previewFile in sourceDirectory.EnumerateFiles(
+ $"{Path.GetFileNameWithoutExtension(originalModelName)}.preview.*",
+ SearchOption.TopDirectoryOnly
+ )
+ )
+ {
+ await FileTransfers.MoveFileAsync(
+ previewFile,
+ destinationDirectory.JoinFile(
+ $"{movedModelPath.NameWithoutExtension}.preview{previewFile.Extension}"
+ ),
+ overwrite: true
+ );
+ }
+
+ await modelIndexService.RefreshIndex();
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ var movedRelativePath = Path.GetRelativePath(modelsRoot, movedModelPath);
+ notificationService.Show(
+ "Model moved",
+ wasRenamedForCollision
+ ? $"Detected UNet-only checkpoint and moved it to \"Models/{movedRelativePath}\" (renamed from \"{originalModelName}\" to \"{movedModelPath.Name}\" because that filename already existed)."
+ : $"Detected UNet-only checkpoint and moved it to \"Models/{movedRelativePath}\"."
+ );
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to evaluate or move downloaded checkpoint {FileName}",
+ civitFile.Name
+ );
+ }
+ }
+
+ private static LocalModelFile? PickMostLikelyDownloadedModel(
+ IEnumerable candidates,
+ DirectoryPath modelsRoot,
+ DirectoryPath requestedDestinationDir
+ )
+ {
+ var existingCandidates = candidates
+ .Select(model => new { Model = model, FullPath = model.GetFullPath(modelsRoot) })
+ .Where(x => File.Exists(x.FullPath))
+ .ToList();
+
+ if (existingCandidates.Count == 0)
+ {
+ return null;
+ }
+
+ var preferredCandidates = existingCandidates
+ .Where(x => IsPathWithinDirectory(x.FullPath, requestedDestinationDir))
+ .ToList();
+
+ if (preferredCandidates.Count == 0)
+ {
+ return null;
+ }
+
+ return preferredCandidates
+ .OrderByDescending(x => File.GetLastWriteTimeUtc(x.FullPath))
+ .Select(x => x.Model)
+ .FirstOrDefault();
+ }
+
+ private static bool IsPathWithinDirectory(string candidatePath, string directoryPath)
+ {
+ var normalizedCandidate = Path.GetFullPath(candidatePath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var normalizedDirectory = Path.GetFullPath(directoryPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ if (string.Equals(normalizedCandidate, normalizedDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return normalizedCandidate.StartsWith(
+ normalizedDirectory + Path.DirectorySeparatorChar,
+ StringComparison.OrdinalIgnoreCase
+ );
+ }
+
private IReadOnlyDictionary GetOtherMetadata(CivitImageGenerationDataResponse value)
{
var metaDict = new Dictionary();
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
index 589f4d255..5e27d0c21 100644
--- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
@@ -32,13 +32,19 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
///
public CheckpointBrowserViewModel(
CivitAiBrowserViewModel civitAiBrowserViewModel,
+ CivArchiveBrowserViewModel civArchiveBrowserViewModel,
HuggingFacePageViewModel huggingFaceViewModel,
OpenModelDbBrowserViewModel openModelDbBrowserViewModel
)
{
Pages = new List(
new List(
- [civitAiBrowserViewModel, huggingFaceViewModel, openModelDbBrowserViewModel]
+ [
+ civitAiBrowserViewModel,
+ civArchiveBrowserViewModel,
+ huggingFaceViewModel,
+ openModelDbBrowserViewModel,
+ ]
).Select(vm => new TabItem { Header = vm.Header, Content = vm })
);
SelectedPage = Pages.FirstOrDefault();
diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
index 62bea1c0f..16f5764d3 100644
--- a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
@@ -20,6 +20,7 @@
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Models.CheckpointOrganizer;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
@@ -56,6 +57,7 @@ public partial class CheckpointsPageViewModel(
INotificationService notificationService,
IMetadataImportService metadataImportService,
IModelImportService modelImportService,
+ ModelOrganizationService modelOrganizationService,
OpenModelDbManager openModelDbManager,
IServiceManager dialogFactory,
ICivitBaseModelTypeService baseModelTypeService,
@@ -590,6 +592,93 @@ private async Task ScanMetadata(bool updateExistingMetadata)
notificationService.Show("Scan Complete", message, NotificationType.Success);
}
+ [RelayCommand]
+ private async Task OrganizeModelsAsync()
+ {
+ if (SelectedCategory == null)
+ {
+ notificationService.Show(
+ "No Category Selected",
+ "Please select a category to organize.",
+ NotificationType.Error
+ );
+ return;
+ }
+
+ var organizeDialogVm = dialogFactory.Get();
+ organizeDialogVm.Initialize(
+ modelIndexService.ModelIndex.Values.SelectMany(x => x),
+ settingsManager.ModelsDirectory,
+ SelectedCategory.Path,
+ ShowModelsInSubfolders,
+ settingsManager.Settings.ModelOrganizationFileNamePattern
+ );
+
+ if (organizeDialogVm.Plan?.Items.Count == 0)
+ {
+ notificationService.Show(
+ "Nothing To Organize",
+ "No indexed models matched the selected category.",
+ NotificationType.Information
+ );
+ return;
+ }
+
+ var dialogResult = await organizeDialogVm.GetDialog().ShowAsync();
+
+ if (dialogResult == ContentDialogResult.Secondary)
+ {
+ switch (organizeDialogVm.RequestedMetadataAction)
+ {
+ case ModelOrganizationMetadataAction.ScanMissing:
+ await ScanMetadata(false);
+ break;
+ case ModelOrganizationMetadataAction.UpdateExisting:
+ await ScanMetadata(true);
+ break;
+ }
+
+ return;
+ }
+
+ if (dialogResult != ContentDialogResult.Primary)
+ return;
+
+ var plan = organizeDialogVm.Plan!;
+
+ IsLoading = true;
+ Progress.Text = "Organizing models...";
+ Progress.IsIndeterminate = true;
+
+ try
+ {
+ var result = await modelOrganizationService.ApplyPlan(plan);
+ await modelIndexService.RefreshIndex();
+
+ var summary =
+ $"{result.MovedCount} moved, {result.ConflictCount} conflicts, {result.SkippedCount} skipped.";
+ notificationService.Show(
+ "Organization Complete",
+ summary,
+ result.Errors.Count == 0 ? NotificationType.Success : NotificationType.Warning
+ );
+
+ if (result.Errors.Count > 0)
+ {
+ notificationService.ShowPersistent(
+ "Organization encountered errors",
+ string.Join(Environment.NewLine, result.Errors.Take(5)),
+ NotificationType.Warning
+ );
+ }
+ }
+ finally
+ {
+ IsLoading = false;
+ Progress.ClearProgress();
+ }
+ }
+
[RelayCommand]
private Task OnItemClick(CheckpointFileViewModel item)
{
@@ -607,7 +696,10 @@ private Task OnItemClick(CheckpointFileViewModel item)
[RelayCommand]
private async Task ShowVersionDialog(CheckpointFileViewModel item)
{
- if (item.CheckpointFile is { HasCivitMetadata: false, HasOpenModelDbMetadata: false })
+ if (
+ item.CheckpointFile is
+ { HasCivitMetadata: false, HasOpenModelDbMetadata: false, HasCivArchiveMetadata: false }
+ )
{
notificationService.Show(
"Cannot show version dialog",
@@ -625,6 +717,32 @@ private async Task ShowVersionDialog(CheckpointFileViewModel item)
{
await ShowOpenModelDbDialog(item);
}
+ else if (item.CheckpointFile.HasCivArchiveMetadata)
+ {
+ ShowCivArchiveDialog(item);
+ }
+ }
+
+ private void ShowCivArchiveDialog(CheckpointFileViewModel item)
+ {
+ var sourceUrl = item.CheckpointFile.ConnectedModelInfo?.SourceUrl;
+ if (string.IsNullOrWhiteSpace(sourceUrl))
+ {
+ notificationService.Show(
+ "CivArchive link unavailable",
+ "This model was downloaded before navigation back to CivArchive was supported. Re-download from CivArchive to enable this.",
+ NotificationType.Warning
+ );
+ return;
+ }
+
+ var newVm = dialogFactory.Get(vm =>
+ {
+ vm.RelativeUrl = sourceUrl;
+ return vm;
+ });
+
+ navigationService.NavigateTo(newVm, BetterSlideNavigationTransition.PageSlideFromRight);
}
private void ShowCivitVersionDialog(CheckpointFileViewModel item)
diff --git a/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs
index bc655863e..99d1444e8 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs
@@ -24,14 +24,23 @@ namespace StabilityMatrix.Avalonia.ViewModels.Controls;
[RegisterTransient]
[ManagedService]
-public partial class PaintCanvasViewModel(ILogger logger) : LoadableViewModelBase
+public partial class PaintCanvasViewModel(ILogger logger)
+ : LoadableViewModelBase,
+ IDisposable
{
+ private bool _disposed;
public ConcurrentDictionary TemporaryPaths { get; set; } = new();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(UndoCommand))]
private ImmutableList paths = [];
+ ///
+ /// Stack of undone paths for redo functionality.
+ ///
+ [JsonIgnore]
+ private readonly Stack redoStack = new();
+
[ObservableProperty]
private Color? paintBrushColor = Colors.White;
@@ -43,6 +52,13 @@ public partial class PaintCanvasViewModel(ILogger logger)
[ObservableProperty]
private double paintBrushAlpha = 1;
+ ///
+ /// Feathering amount for soft brush edges. 0 = hard edge, 1 = fully soft/blurred.
+ /// UI typically shows this inverted as "Hardness" (100% = no feathering).
+ ///
+ [ObservableProperty]
+ private double paintBrushFeathering = 0;
+
[ObservableProperty]
private double currentPenPressure;
@@ -58,6 +74,18 @@ public partial class PaintCanvasViewModel(ILogger logger)
[ObservableProperty]
private Size canvasSize = Size.Empty;
+ ///
+ /// Whether drawing is enabled. Set to false to disable brush strokes (e.g., for image reference layers).
+ ///
+ [ObservableProperty]
+ private bool isDrawingEnabled = true;
+
+ ///
+ /// Whether to draw shapes (Rectangle/Ellipse) as strokes only instead of filled.
+ ///
+ [ObservableProperty]
+ private bool isShapeStrokeOnly;
+
[JsonIgnore]
private SKCanvas? SourceCanvas { set; get; }
@@ -67,8 +95,9 @@ public partial class PaintCanvasViewModel(ILogger logger)
new()
{
["Background"] = new SKLayer(),
- ["Images"] = new SKLayer(),
- ["Brush"] = new SKLayer(),
+ ["Images"] = new SKLayer(), // Layers BELOW the selected layer
+ ["Brush"] = new SKLayer(), // The currently selected/active layer
+ ["Overlay"] = new SKLayer(), // Layers ABOVE the selected layer
};
[JsonIgnore]
@@ -77,9 +106,109 @@ public partial class PaintCanvasViewModel(ILogger logger)
[JsonIgnore]
private SKLayer ImagesLayer => Layers["Images"];
+ [JsonIgnore]
+ private SKLayer OverlayLayer => Layers["Overlay"];
+
[JsonIgnore]
private SKLayer BackgroundLayer => Layers["Background"];
+ ///
+ /// Cached bitmap of all finalized paths. Cleared when paths change.
+ ///
+ [JsonIgnore]
+ private SKImage? cachedPathsImage;
+
+ ///
+ /// Number of paths that were rendered into the cached image.
+ /// Used to determine if cache needs to be updated.
+ ///
+ [JsonIgnore]
+ private int cachedPathsCount;
+
+ ///
+ /// Cached surface for temporary paths during active drawing.
+ /// Allows incremental rendering of long strokes.
+ ///
+ [JsonIgnore]
+ private SKSurface? tempPathSurface;
+
+ ///
+ /// Tracks how many points have been rendered to the temp path surface per pointer ID.
+ ///
+ [JsonIgnore]
+ private readonly ConcurrentDictionary tempPathRenderedPoints = new();
+
+ ///
+ /// Whether to use GPU-accelerated surfaces when available.
+ ///
+ [JsonIgnore]
+ public bool UseGpuAcceleration { get; set; } = true;
+
+ ///
+ /// Indicates whether GPU acceleration is currently active.
+ ///
+ [JsonIgnore]
+ public bool IsUsingGpu { get; private set; }
+
+ ///
+ /// Debug flag: Set to true to log GPU/CPU surface creation.
+ ///
+ [JsonIgnore]
+ public static bool LogRenderingMode { get; set; }
+#if DEBUG
+ = true;
+#endif
+
+ ///
+ /// Whether to show a checkerboard pattern for transparent areas.
+ ///
+ [JsonIgnore]
+ public bool ShowCheckerboardBackground { get; set; } = true;
+
+ ///
+ /// Size of each checkerboard square in pixels.
+ ///
+ private const int CheckerboardSquareSize = 16;
+
+ ///
+ /// Light color for the checkerboard pattern.
+ ///
+ private static readonly SKColor CheckerboardLight = new(220, 220, 220);
+
+ ///
+ /// Dark color for the checkerboard pattern.
+ ///
+ private static readonly SKColor CheckerboardDark = new(180, 180, 180);
+
+ ///
+ /// Cached checkerboard shader for efficient rendering.
+ ///
+ [JsonIgnore]
+ private SKShader? cachedCheckerboardShader;
+
+ ///
+ /// The canvas size that the cached checkerboard shader was created for.
+ ///
+ [JsonIgnore]
+ private Size cachedCheckerboardSize;
+
+ ///
+ /// Whether to show a grid overlay for alignment assistance.
+ ///
+ [ObservableProperty]
+ private bool showGridOverlay;
+
+ ///
+ /// Number of grid divisions (e.g., 3 for rule of thirds).
+ ///
+ [ObservableProperty]
+ private int gridDivisions = 3;
+
+ ///
+ /// Color for the grid overlay lines.
+ ///
+ private static readonly SKColor GridLineColor = new(128, 128, 128, 180);
+
[JsonIgnore]
public SKBitmap? BackgroundImage
{
@@ -106,16 +235,64 @@ public SKBitmap? BackgroundImage
[JsonIgnore]
public Action? RefreshCanvas { get; set; }
+ ///
+ /// Sets or clears a bitmap for a compositing layer.
+ /// Used for displaying other layers when compositing in a layered editor.
+ ///
+ ///
+ /// Layer name: "Images" for layers below the selected layer,
+ /// "Overlay" for layers above the selected layer,
+ /// or legacy "OtherLayers" which maps to "Images" for backwards compatibility.
+ ///
+ /// The bitmap to set, or null to clear
+ public void SetLayerBitmap(string name, SKBitmap? bitmap)
+ {
+ // Map legacy name to new name for backwards compatibility
+ var layerName = name switch
+ {
+ "OtherLayers" => "Images", // Legacy: all other layers went to Images
+ "LayersBelow" => "Images",
+ "LayersAbove" => "Overlay",
+ "CurrentImage" => "Brush", // Selected image layer bitmap goes to Brush layer
+ _ => name,
+ };
+
+ if (!Layers.TryGetValue(layerName, out var layer))
+ {
+ return;
+ }
+
+ // Dispose old bitmaps before replacing to prevent memory leaks
+ lock (layer)
+ {
+ foreach (var oldBitmap in layer.Bitmaps)
+ {
+ oldBitmap.Dispose();
+ }
+
+ layer.Bitmaps = bitmap is not null ? [bitmap] : [];
+ }
+ }
+
public void SetSourceCanvas(SKCanvas canvas)
{
- ArgumentNullException.ThrowIfNull(canvas, nameof(canvas));
+ ArgumentNullException.ThrowIfNull(canvas);
SourceCanvas = canvas;
}
public void LoadCanvasFromBitmap(SKBitmap bitmap)
{
- ImagesLayer.Bitmaps = [bitmap];
+ // Dispose old bitmaps and invalidate cache
+ lock (ImagesLayer)
+ {
+ foreach (var oldBitmap in ImagesLayer.Bitmaps)
+ {
+ oldBitmap.Dispose();
+ }
+ ImagesLayer.Bitmaps = [bitmap];
+ }
+ InvalidatePathCache();
RefreshCanvas?.Invoke();
}
@@ -130,175 +307,1243 @@ public void Undo()
return;
}
+ // Push the removed path to redo stack
+ var removedPath = currentPaths[^1];
+ redoStack.Push(removedPath);
+ RedoCommand.NotifyCanExecuteChanged();
+
Paths = currentPaths.RemoveAt(currentPaths.Count - 1);
- RefreshCanvas?.Invoke();
- }
+ // Invalidate cache since paths changed
+ InvalidatePathCache();
- private bool CanExecuteUndo()
- {
- return Paths.Count > 0;
+ RefreshCanvas?.Invoke();
}
- public SKImage? RenderToWhiteChannelImage()
+ [RelayCommand(CanExecute = nameof(CanExecuteRedo))]
+ public void Redo()
{
- using var _ = CodeTimer.StartDebug();
-
- if (CanvasSize == Size.Empty)
+ if (redoStack.Count == 0)
{
- logger.LogWarning($"RenderToImage: {nameof(CanvasSize)} is not set, returning null.");
- return null;
+ return;
}
- using var surface = SKSurface.Create(new SKImageInfo(CanvasSize.Width, CanvasSize.Height));
+ var pathToRestore = redoStack.Pop();
+ Paths = Paths.Add(pathToRestore);
+ RedoCommand.NotifyCanExecuteChanged();
- RenderToSurface(surface);
+ // Invalidate cache since paths changed
+ InvalidatePathCache();
- using var originalImage = surface.Snapshot();
- // Replace all colors to white (255, 255, 255), keep original alpha
- // csharpier-ignore
- using var colorFilter = SKColorFilter.CreateColorMatrix(
- [
- // R, G, B, A, Bias
- -1, 0, 0, 0, 255,
- 0, -1, 0, 0, 255,
- 0, 0, -1, 0, 255,
- 0, 0, 0, 1, 0
- ]
- );
+ RefreshCanvas?.Invoke();
+ }
- using var paint = new SKPaint();
- paint.ColorFilter = colorFilter;
+ ///
+ /// Invalidates the cached paths image. Call when paths are modified externally.
+ ///
+ public void InvalidatePathCache()
+ {
+ cachedPathsImage?.Dispose();
+ cachedPathsImage = null;
+ cachedPathsCount = 0;
+ }
- surface.Canvas.Clear(SKColors.Transparent);
- surface.Canvas.DrawImage(originalImage, originalImage.Info.Rect, paint);
+ ///
+ /// Called when the Paths property changes.
+ /// Invalidates the cache since we have a completely new set of paths.
+ ///
+ partial void OnPathsChanged(ImmutableList value)
+ {
+ // When paths change (e.g., layer switch), invalidate the cache
+ // since the cached image is from the old paths
+ InvalidatePathCache();
+ }
- return surface.Snapshot();
+ private bool CanExecuteUndo()
+ {
+ return Paths.Count > 0;
}
- public SKImage? RenderToImage()
+ private bool CanExecuteRedo()
{
- using var _ = CodeTimer.StartDebug();
+ return redoStack.Count > 0;
+ }
- if (CanvasSize == Size.Empty)
+ ///
+ /// Clears the redo stack. Call when new paths are added (not via redo).
+ ///
+ public void ClearRedoStack()
+ {
+ if (redoStack.Count > 0)
{
- logger.LogWarning($"RenderToImage: {nameof(CanvasSize)} is not set, returning null.");
- return null;
+ redoStack.Clear();
+ RedoCommand.NotifyCanExecuteChanged();
}
+ }
- using var surface = SKSurface.Create(new SKImageInfo(CanvasSize.Width, CanvasSize.Height));
+ #region Shape Tool State
- RenderToSurface(surface);
+ ///
+ /// Starting point for shape drawing (Rectangle/Ellipse tools).
+ ///
+ [ObservableProperty]
+ [property: JsonIgnore]
+ private SKPoint? shapeStartPoint;
- return surface.Snapshot();
- }
+ ///
+ /// Pointer ID for the current shape drawing operation.
+ ///
+ [ObservableProperty]
+ [property: JsonIgnore]
+ private long shapePointerId;
- public void RenderToSurface(
- SKSurface surface,
- bool renderBackgroundFill = false,
- bool renderBackgroundImage = false
- )
- {
- // Initialize canvas layers
- foreach (var layer in Layers.Values)
- {
- lock (layer)
- {
- if (layer.Surface is null)
- {
- layer.Surface = SKSurface.Create(new SKImageInfo(CanvasSize.Width, CanvasSize.Height));
- /*layer.Surface = SKSurface.Create(
- surface.Context,
- true,
- new SKImageInfo(CanvasSize.Width, CanvasSize.Height)
- );*/
- }
- else
- {
- // If we need to resize:
- var currentInfo = layer.Surface.Canvas.DeviceClipBounds;
- if (currentInfo.Width != CanvasSize.Width || currentInfo.Height != CanvasSize.Height)
- {
- // Dispose the old surface
- layer.Surface.Dispose();
+ ///
+ /// Returns true if the currently selected tool is a shape tool.
+ ///
+ [JsonIgnore]
+ public bool IsShapeTool => SelectedTool is PaintCanvasTool.Rectangle or PaintCanvasTool.Ellipse;
- // Create a brand-new SKSurface with the new size
- layer.Surface = SKSurface.Create(
- new SKImageInfo(CanvasSize.Width, CanvasSize.Height)
- );
- }
- else
- {
- // No resize needed, just clear
- layer.Surface.Canvas.Clear(SKColors.Transparent);
- }
- }
- }
- }
+ #endregion
- // Render all layer images in order
- foreach (var (layerName, layer) in Layers)
- {
- // Skip background image if not requested
- if (!renderBackgroundImage && layerName == "Background")
- {
- continue;
- }
+ #region Move Tool State
- lock (layer)
- {
- var layerCanvas = layer.Surface!.Canvas;
- foreach (var bitmap in layer.Bitmaps)
- {
- layerCanvas.DrawBitmap(bitmap, new SKPoint(0, 0));
- }
- }
- }
+ ///
+ /// Starting point for move operations.
+ ///
+ [ObservableProperty]
+ [property: JsonIgnore]
+ private SKPoint? moveStartPoint;
- // Render paint layer
- var paintLayerCanvas = BrushLayer.Surface!.Canvas;
+ ///
+ /// Layer offset at the start of a move operation.
+ ///
+ [ObservableProperty]
+ [property: JsonIgnore]
+ private SKPoint moveStartOffset;
- using var paint = new SKPaint();
+ ///
+ /// Returns true if the currently selected tool is the move tool.
+ ///
+ [JsonIgnore]
+ public bool IsMoveTool => SelectedTool == PaintCanvasTool.Move;
- // Draw the paths
- foreach (var penPath in Paths)
- {
- RenderPenPath(paintLayerCanvas, penPath, paint);
- }
+ ///
+ /// Callback invoked when the move tool adjusts the image layer position.
+ /// Parameters: (newOffsetX, newOffsetY) - the new absolute offset position.
+ ///
+ [JsonIgnore]
+ public Action? OnMoveToolDrag { get; set; }
- foreach (var penPath in TemporaryPaths.Values)
- {
- RenderPenPath(paintLayerCanvas, penPath, paint);
- }
+ ///
+ /// Callback to get the current image layer offset when starting a move.
+ /// Returns (currentOffsetX, currentOffsetY).
+ ///
+ [JsonIgnore]
+ public Func<(double X, double Y)>? GetCurrentMoveOffset { get; set; }
- // Draw background color
- surface.Canvas.Clear(SKColors.Transparent);
+ ///
+ /// Starts a move operation at the given position.
+ ///
+ public void StartMove(SKPoint position, double currentOffsetX, double currentOffsetY)
+ {
+ MoveStartPoint = position;
+ MoveStartOffset = new SKPoint((float)currentOffsetX, (float)currentOffsetY);
+ }
- // Draw the layers to the main surface
- foreach (var layer in Layers.Values)
- {
- lock (layer)
- {
- layer.Surface!.Canvas.Flush();
+ ///
+ /// Updates the move during drag, calculating delta from start position.
+ ///
+ public void UpdateMove(SKPoint currentPoint)
+ {
+ if (!MoveStartPoint.HasValue)
+ return;
- surface.Canvas.DrawSurface(layer.Surface!, new SKPoint(0, 0));
- }
- }
+ var deltaX = currentPoint.X - MoveStartPoint.Value.X;
+ var deltaY = currentPoint.Y - MoveStartPoint.Value.Y;
- surface.Canvas!.Flush();
+ // Invoke callback with new absolute offset
+ OnMoveToolDrag?.Invoke(MoveStartOffset.X + deltaX, MoveStartOffset.Y + deltaY);
}
- private static void RenderPenPath(SKCanvas canvas, PenPath penPath, SKPaint paint)
+ ///
+ /// Ends the current move operation.
+ ///
+ public void EndMove()
{
- if (penPath.Points.Count == 0)
- {
- return;
- }
+ MoveStartPoint = null;
+ }
+
+ #endregion
+
+ #region Canvas Commands
+
+ ///
+ /// Clears all paths from the canvas.
+ ///
+ [RelayCommand]
+ public void ClearCanvas()
+ {
+ Paths = ImmutableList.Empty;
+ TemporaryPaths.Clear();
+ redoStack.Clear();
+ RedoCommand.NotifyCanExecuteChanged();
+ InvalidatePathCache();
+ RefreshCanvas?.Invoke();
+ }
+
+ #endregion
+
+ #region Tool Selection Commands
+
+ [RelayCommand]
+ public void SelectBrushTool() => SelectedTool = PaintCanvasTool.PaintBrush;
+
+ [RelayCommand]
+ public void SelectEraserTool() => SelectedTool = PaintCanvasTool.Eraser;
+
+ [RelayCommand]
+ public void SelectRectangleTool() => SelectedTool = PaintCanvasTool.Rectangle;
+
+ [RelayCommand]
+ public void SelectEllipseTool() => SelectedTool = PaintCanvasTool.Ellipse;
+
+ [RelayCommand]
+ public void SelectMoveTool() => SelectedTool = PaintCanvasTool.Move;
+
+ #endregion
+
+ #region Brush Size Commands
+
+ [RelayCommand]
+ public void IncreaseBrushSize()
+ {
+ PaintBrushSize = Math.Min(100, PaintBrushSize + 5);
+ }
+
+ [RelayCommand]
+ public void DecreaseBrushSize()
+ {
+ PaintBrushSize = Math.Max(1, PaintBrushSize - 5);
+ }
+
+ #endregion
+
+ #region Shape Drawing Helpers
+
+ ///
+ /// Starts shape drawing at the given position.
+ ///
+ public void StartShapeDrawing(SKPoint position, long pointerId)
+ {
+ ShapeStartPoint = position;
+ ShapePointerId = pointerId;
+ }
+
+ ///
+ /// Updates the shape preview during drag.
+ ///
+ public void UpdateShapePreview(SKPoint currentPoint)
+ {
+ if (!ShapeStartPoint.HasValue)
+ return;
+
+ var bounds = CreateBoundsFromPoints(ShapeStartPoint.Value, currentPoint);
+ var previewPath = new PenPath
+ {
+ FillColor = PaintBrushSKColor.WithAlpha((byte)(PaintBrushAlpha * 255)),
+ PathType =
+ SelectedTool == PaintCanvasTool.Rectangle ? PenPathType.Rectangle : PenPathType.Ellipse,
+ Bounds = bounds,
+ IsStrokeOnly = IsShapeStrokeOnly,
+ StrokeWidth = (float)PaintBrushSize,
+ };
+ TemporaryPaths[ShapePointerId] = previewPath;
+ }
+
+ ///
+ /// Finalizes the shape drawing and adds it to paths.
+ ///
+ /// The created shape path, or null if shape was too small.
+ public PenPath? FinalizeShape(SKPoint endPoint)
+ {
+ if (!ShapeStartPoint.HasValue)
+ return null;
+
+ var bounds = CreateBoundsFromPoints(ShapeStartPoint.Value, endPoint);
+
+ // Only create shape if it has meaningful size
+ if (bounds.Width <= 2 || bounds.Height <= 2)
+ {
+ ShapeStartPoint = null;
+ TemporaryPaths.TryRemove(ShapePointerId, out _);
+ return null;
+ }
+
+ var shapePath = new PenPath
+ {
+ FillColor = PaintBrushSKColor.WithAlpha((byte)(PaintBrushAlpha * 255)),
+ IsErase = SelectedTool == PaintCanvasTool.Eraser,
+ PathType =
+ SelectedTool == PaintCanvasTool.Rectangle ? PenPathType.Rectangle : PenPathType.Ellipse,
+ Bounds = bounds,
+ IsStrokeOnly = IsShapeStrokeOnly,
+ StrokeWidth = (float)PaintBrushSize,
+ };
+
+ Paths = Paths.Add(shapePath);
+ ClearRedoStack(); // New path added, clear redo history
+ ShapeStartPoint = null;
+ TemporaryPaths.TryRemove(ShapePointerId, out _);
+
+ return shapePath;
+ }
+
+ ///
+ /// Cancels the current shape drawing operation.
+ ///
+ public void CancelShapeDrawing()
+ {
+ ShapeStartPoint = null;
+ TemporaryPaths.TryRemove(ShapePointerId, out _);
+ }
+
+ private static SKRect CreateBoundsFromPoints(SKPoint start, SKPoint end)
+ {
+ return new SKRect(
+ Math.Min(start.X, end.X),
+ Math.Min(start.Y, end.Y),
+ Math.Max(start.X, end.X),
+ Math.Max(start.Y, end.Y)
+ );
+ }
+
+ #endregion
+
+ #region Paint Bucket / Flood Fill
+
+ [RelayCommand]
+ public void SelectPaintBucketTool() => SelectedTool = PaintCanvasTool.PaintBucket;
+
+ ///
+ /// Performs a flood fill at the specified point.
+ /// Returns the created path, or null if fill wasn't possible.
+ ///
+ public PenPath? FloodFillAt(SKPoint clickPoint, SKColor fillColor)
+ {
+ if (CanvasSize == Size.Empty)
+ return null;
+
+ var x = (int)clickPoint.X;
+ var y = (int)clickPoint.Y;
+
+ // Bounds check
+ if (x < 0 || x >= CanvasSize.Width || y < 0 || y >= CanvasSize.Height)
+ return null;
+
+ // Get the current state of the canvas on CPU to avoid GPU context threading issues ("Could not allocate vertices")
+ // and to ensure we don't accidentally fill the checkerboard pattern.
+ using var sourceBitmap = GetFlattenedContentBitmap();
+ var targetColor = sourceBitmap.GetPixel(x, y);
+
+ // Don't fill if clicking on the same color (with some tolerance for anti-aliasing)
+ if (ColorsAreSimilar(targetColor, fillColor, tolerance: 30))
+ return null;
+
+ // Create a surface for drawing the fill result
+ using var surface = SKSurface.Create(
+ new SKImageInfo(CanvasSize.Width, CanvasSize.Height, SKColorType.Rgba8888, SKAlphaType.Premul)
+ );
+ var canvas = surface.Canvas;
+ canvas.Clear(SKColors.Transparent);
+
+ // Perform flood fill and draw horizontal spans
+ var hasContent = ScanlineFillWithCanvas(sourceBitmap, canvas, x, y, targetColor, fillColor);
+
+ if (!hasContent)
+ {
+ return null;
+ }
+
+ // Copy the surface to the bitmap
+ canvas.Flush();
+ using var filledImage = surface.Snapshot();
+
+ // Create a new bitmap with the filled content
+ var resultBitmap = new SKBitmap(
+ CanvasSize.Width,
+ CanvasSize.Height,
+ SKColorType.Rgba8888,
+ SKAlphaType.Premul
+ );
+ using var resultCanvas = new SKCanvas(resultBitmap);
+ resultCanvas.DrawImage(filledImage, 0, 0);
+ resultCanvas.Flush();
+
+ // Create a bitmap path with the fill result
+ var fillPath = new PenPath
+ {
+ PathType = PenPathType.Bitmap,
+ FillColor = fillColor,
+ BitmapData = resultBitmap,
+ Bounds = new SKRect(0, 0, CanvasSize.Width, CanvasSize.Height),
+ };
+
+ Paths = Paths.Add(fillPath);
+ ClearRedoStack(); // New path added, clear redo history
+ InvalidatePathCache();
+ RefreshCanvas?.Invoke();
+
+ return fillPath;
+ }
+
+ ///
+ /// Generates a flattened bitmap of the current canvas content (Layers + Paths).
+ /// Runs strictly on CPU to avoid GPU threading/context issues during Flood Fill.
+ /// Ignores checkerboard background to ensure correct filling of transparent areas.
+ ///
+ private SKBitmap GetFlattenedContentBitmap()
+ {
+ var width = CanvasSize.Width;
+ var height = CanvasSize.Height;
+ var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
+
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Transparent);
+
+ // Draw all layers in order
+ foreach (var (name, layer) in Layers)
+ {
+ lock (layer)
+ {
+ foreach (var layerBitmap in layer.Bitmaps)
+ {
+ canvas.DrawBitmap(layerBitmap, 0, 0);
+ }
+
+ // If this is the active brush layer, also render the active vector paths
+ // We render them freshly here on CPU to avoid using the GPU-backed cache from a different thread
+ if (name == "Brush")
+ {
+ using var paint = new SKPaint();
+ foreach (var path in Paths)
+ {
+ RenderPenPath(canvas, path, paint);
+ }
+ }
+ }
+ }
+
+ canvas.Flush();
+ return bitmap;
+ }
+
+ ///
+ /// Scanline flood fill that draws horizontal spans to an SKCanvas.
+ /// Returns true if any pixels were filled.
+ ///
+ private static bool ScanlineFillWithCanvas(
+ SKBitmap source,
+ SKCanvas canvas,
+ int startX,
+ int startY,
+ SKColor targetColor,
+ SKColor fillColor
+ )
+ {
+ var width = source.Width;
+ var height = source.Height;
+
+ // Use SKBitmap.Pixels which is platform-agnostic (returns SKColor[])
+ var sourcePixels = source.Pixels;
+
+ var visited = new bool[width * height];
+ var queue = new Queue<(int x, int y)>();
+ queue.Enqueue((startX, startY));
+
+ // Collect horizontal spans to draw
+ var spans = new List<(int y, int left, int right)>();
+
+ // Increased tolerance to better catch anti-aliased edges
+ const int Tolerance = 50;
+ // Increased expansion to ensuring we fully cover the semi-transparent border pixels
+ const float Expand = 1.5f;
+
+ using var paint = new SKPaint
+ {
+ Color = fillColor,
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true, // Smooth edges for the dilated rects
+ BlendMode = SKBlendMode.Src, // Replace mode prevents alpha buildup on overlapping dilated scanlines
+ };
+
+ while (queue.Count > 0)
+ {
+ var (x, y) = queue.Dequeue();
+
+ // Bounds check
+ if (x < 0 || x >= width || y < 0 || y >= height)
+ continue;
+
+ var index = y * width + x;
+ if (visited[index])
+ continue;
+
+ var pixel = sourcePixels[index];
+ if (!ColorsAreSimilar(pixel, targetColor, tolerance: Tolerance))
+ continue;
+
+ // Mark as visited
+ visited[index] = true;
+
+ // Scanline approach: find the entire horizontal span
+ var left = x;
+ var right = x;
+
+ // Extend left
+ while (left > 0)
+ {
+ var leftIndex = y * width + (left - 1);
+ if (visited[leftIndex])
+ break;
+ var leftPixel = sourcePixels[leftIndex];
+ if (!ColorsAreSimilar(leftPixel, targetColor, tolerance: Tolerance))
+ break;
+ left--;
+ visited[leftIndex] = true;
+ }
+
+ // Extend right
+ while (right < width - 1)
+ {
+ var rightIndex = y * width + (right + 1);
+ if (visited[rightIndex])
+ break;
+ var rightPixel = sourcePixels[rightIndex];
+ if (!ColorsAreSimilar(rightPixel, targetColor, tolerance: Tolerance))
+ break;
+ right++;
+ visited[rightIndex] = true;
+ }
+
+ // Draw this span as a filled rectangle with slight expansion
+ // Using DrawRect with float coordinates allows sub-pixel expansion
+ canvas.DrawRect(
+ left - Expand,
+ y - Expand,
+ (right - left + 1) + (Expand * 2),
+ 1 + (Expand * 2),
+ paint
+ );
+
+ // Queue pixels above and below the span
+ for (var i = left; i <= right; i++)
+ {
+ if (y > 0)
+ {
+ var aboveIndex = (y - 1) * width + i;
+ if (!visited[aboveIndex])
+ {
+ var abovePixel = sourcePixels[aboveIndex];
+ if (ColorsAreSimilar(abovePixel, targetColor, tolerance: Tolerance))
+ queue.Enqueue((i, y - 1));
+ }
+ }
+
+ if (y < height - 1)
+ {
+ var belowIndex = (y + 1) * width + i;
+ if (!visited[belowIndex])
+ {
+ var belowPixel = sourcePixels[belowIndex];
+ if (ColorsAreSimilar(belowPixel, targetColor, tolerance: Tolerance))
+ queue.Enqueue((i, y + 1));
+ }
+ }
+ }
+ }
+
+ // Check if anything was filled (at least one visited pixel)
+ foreach (var v in visited)
+ {
+ if (v)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool ColorsAreSimilar(SKColor a, SKColor b, int tolerance)
+ {
+ // Handle transparent pixels specially
+ if (a.Alpha < 10 && b.Alpha < 10)
+ return true;
+ if (a.Alpha < 10 || b.Alpha < 10)
+ return Math.Abs(a.Alpha - b.Alpha) <= tolerance;
+
+ return Math.Abs(a.Red - b.Red) <= tolerance
+ && Math.Abs(a.Green - b.Green) <= tolerance
+ && Math.Abs(a.Blue - b.Blue) <= tolerance
+ && Math.Abs(a.Alpha - b.Alpha) <= tolerance;
+ }
+
+ #endregion
+
+ public SKImage? RenderToWhiteChannelImage()
+ {
+ using var _ = CodeTimer.StartDebug();
+
+ if (CanvasSize == Size.Empty)
+ {
+ logger.LogWarning($"RenderToImage: {nameof(CanvasSize)} is not set, returning null.");
+ return null;
+ }
+
+ using var surface = SKSurface.Create(new SKImageInfo(CanvasSize.Width, CanvasSize.Height));
+
+ RenderToSurface(surface);
+
+ using var originalImage = surface.Snapshot();
+ // Replace all colors to white (255, 255, 255), keep original alpha
+ // csharpier-ignore
+ using var colorFilter = SKColorFilter.CreateColorMatrix(
+ [
+ // R, G, B, A, Bias
+ -1, 0, 0, 0, 255,
+ 0, -1, 0, 0, 255,
+ 0, 0, -1, 0, 255,
+ 0, 0, 0, 1, 0
+ ]
+ );
+
+ using var paint = new SKPaint();
+ paint.ColorFilter = colorFilter;
+
+ surface.Canvas.Clear(SKColors.Transparent);
+ surface.Canvas.DrawImage(originalImage, originalImage.Info.Rect, paint);
+
+ return surface.Snapshot();
+ }
+
+ public SKImage? RenderToImage()
+ {
+ using var _ = CodeTimer.StartDebug();
+
+ if (CanvasSize == Size.Empty)
+ {
+ logger.LogWarning($"RenderToImage: {nameof(CanvasSize)} is not set, returning null.");
+ return null;
+ }
+
+ using var surface = SKSurface.Create(new SKImageInfo(CanvasSize.Width, CanvasSize.Height));
+
+ RenderToSurface(surface);
+
+ return surface.Snapshot();
+ }
+
+ ///
+ /// Extracts masks for multiple colors in a single render pass.
+ /// More efficient than calling ExtractMaskByColor multiple times.
+ ///
+ /// The colors to extract masks for.
+ /// RGB tolerance for color matching (0-255). Default 10.
+ /// A dictionary mapping each color to its mask image.
+ public Dictionary ExtractMasksByColors(
+ IReadOnlyList targetColors,
+ int tolerance = 10
+ )
+ {
+ using var _ = CodeTimer.StartDebug();
+
+ var results = new Dictionary();
+
+ if (CanvasSize == Size.Empty || targetColors.Count == 0)
+ return results;
+
+ // Render canvas once
+ using var renderedImage = RenderToImage();
+ if (renderedImage is null)
+ return results;
+
+ using var sourceBitmap = SKBitmap.FromImage(renderedImage);
+ var srcPixels = sourceBitmap.Pixels; // SKColor[] array - fast direct access
+ var pixelCount = srcPixels.Length;
+
+ // Create result bitmaps for each color
+ var resultBitmaps = new Dictionary();
+ var resultPixels = new Dictionary();
+ foreach (var color in targetColors)
+ {
+ var bitmap = new SKBitmap(
+ sourceBitmap.Width,
+ sourceBitmap.Height,
+ SKColorType.Rgba8888,
+ SKAlphaType.Premul
+ );
+ resultBitmaps[color] = bitmap;
+ resultPixels[color] = new SKColor[pixelCount];
+ }
+
+ // Single pass through pixels, check all colors
+ for (var i = 0; i < pixelCount; i++)
+ {
+ var pixel = srcPixels[i];
+
+ foreach (var targetColor in targetColors)
+ {
+ var matches =
+ Math.Abs(pixel.Red - targetColor.Red) <= tolerance
+ && Math.Abs(pixel.Green - targetColor.Green) <= tolerance
+ && Math.Abs(pixel.Blue - targetColor.Blue) <= tolerance
+ && pixel.Alpha > 0;
+
+ resultPixels[targetColor][i] = matches ? SKColors.White : SKColors.Transparent;
+ }
+ }
+
+ // Set pixels and convert bitmaps to images
+ foreach (var (color, bitmap) in resultBitmaps)
+ {
+ bitmap.Pixels = resultPixels[color];
+ results[color] = SKImage.FromBitmap(bitmap);
+ bitmap.Dispose();
+ }
+
+ return results;
+ }
+
+ ///
+ /// Extracts a mask from the canvas where pixels match the target color.
+ /// Returns a grayscale mask where white = match, transparent = no match.
+ /// Used for regional prompting to separate painted regions by color.
+ ///
+ /// The color to extract.
+ /// RGB tolerance for color matching (0-255). Default 10.
+ /// A mask image, or null if canvas is empty.
+ public SKImage? ExtractMaskByColor(SKColor targetColor, int tolerance = 10)
+ {
+ using var _ = CodeTimer.StartDebug();
+
+ if (CanvasSize == Size.Empty)
+ {
+ logger.LogWarning($"ExtractMaskByColor: {nameof(CanvasSize)} is not set, returning null.");
+ return null;
+ }
+
+ // First render the canvas to get the painted image
+ using var renderedImage = RenderToImage();
+ if (renderedImage is null)
+ return null;
+
+ using var bitmap = SKBitmap.FromImage(renderedImage);
+ var resultBitmap = new SKBitmap(
+ bitmap.Width,
+ bitmap.Height,
+ SKColorType.Rgba8888,
+ SKAlphaType.Premul
+ );
+
+ // Use Pixels array for fast direct access
+ var srcPixels = bitmap.Pixels;
+ var dstPixels = new SKColor[srcPixels.Length];
+
+ for (var i = 0; i < srcPixels.Length; i++)
+ {
+ var pixel = srcPixels[i];
+
+ // Check if pixel matches target color within tolerance
+ var matches =
+ Math.Abs(pixel.Red - targetColor.Red) <= tolerance
+ && Math.Abs(pixel.Green - targetColor.Green) <= tolerance
+ && Math.Abs(pixel.Blue - targetColor.Blue) <= tolerance
+ && pixel.Alpha > 0;
+
+ dstPixels[i] = matches ? SKColors.White : SKColors.Transparent;
+ }
+
+ resultBitmap.Pixels = dstPixels;
+ return SKImage.FromBitmap(resultBitmap);
+ }
+
+ ///
+ /// Gets all unique colors present in the painted canvas (excluding transparent).
+ /// Used for regional prompting to detect which colors the user has painted.
+ ///
+ /// A list of unique colors found in the canvas.
+ public IReadOnlyList GetPaintedColors()
+ {
+ // Default palette colors to match against
+ return GetPaintedColors(
+ [
+ new SKColor(255, 0, 0), // Red
+ new SKColor(255, 128, 0), // Orange
+ new SKColor(255, 255, 0), // Yellow
+ new SKColor(0, 255, 0), // Green
+ new SKColor(0, 128, 255), // Blue
+ new SKColor(128, 0, 255), // Purple
+ ]
+ );
+ }
+
+ ///
+ /// Gets a list of palette colors that have been painted on the canvas.
+ /// Uses tolerance matching to handle anti-aliased edges.
+ ///
+ /// The palette colors to match against.
+ /// RGB tolerance for color matching (default 40 to handle anti-aliasing).
+ /// A list of palette colors that were found in the canvas.
+ public IReadOnlyList GetPaintedColors(IReadOnlyList paletteColors, int tolerance = 40)
+ {
+ if (CanvasSize == Size.Empty)
+ return [];
+
+ using var renderedImage = RenderToImage();
+ if (renderedImage is null)
+ return [];
+
+ using var bitmap = SKBitmap.FromImage(renderedImage);
+ var foundPaletteColors = new HashSet();
+
+ // Use Pixels array for fast direct access
+ var pixels = bitmap.Pixels;
+ var paletteCount = paletteColors.Count;
+
+ foreach (var pixel in pixels)
+ {
+ if (pixel.Alpha < 128) // Skip mostly transparent pixels
+ continue;
+
+ // Find the closest palette color
+ for (var p = 0; p < paletteCount; p++)
+ {
+ var paletteColor = paletteColors[p];
+ if (!ColorMatchesWithTolerance(pixel, paletteColor, tolerance))
+ continue;
+
+ foundPaletteColors.Add(paletteColor);
+
+ // Early exit if we've found all palette colors
+ if (foundPaletteColors.Count == paletteCount)
+ return foundPaletteColors.ToList();
+
+ break;
+ }
+ }
+
+ return foundPaletteColors.ToList();
+ }
+
+ ///
+ /// Checks if two colors match within the specified RGB tolerance.
+ ///
+ private static bool ColorMatchesWithTolerance(SKColor a, SKColor b, int tolerance)
+ {
+ return Math.Abs(a.Red - b.Red) <= tolerance
+ && Math.Abs(a.Green - b.Green) <= tolerance
+ && Math.Abs(a.Blue - b.Blue) <= tolerance;
+ }
+
+ public void RenderToSurface(
+ SKSurface surface,
+ bool renderBackgroundFill = false,
+ bool renderBackgroundImage = false
+ )
+ {
+ var grContext = surface.Context;
+ var useGpu = UseGpuAcceleration && grContext != null;
+ IsUsingGpu = useGpu;
+
+ // Initialize canvas layers
+ foreach (var layer in Layers.Values)
+ {
+ lock (layer)
+ {
+ var needsNewSurface = layer.Surface is null;
+ if (!needsNewSurface)
+ {
+ // Check if we need to resize
+ var currentInfo = layer.Surface!.Canvas.DeviceClipBounds;
+ needsNewSurface =
+ currentInfo.Width != CanvasSize.Width || currentInfo.Height != CanvasSize.Height;
+ }
+
+ if (needsNewSurface)
+ {
+ // Dispose old surface if exists
+ layer.Surface?.Dispose();
+
+ var imageInfo = new SKImageInfo(CanvasSize.Width, CanvasSize.Height);
+
+ // Try GPU surface first if available
+ if (useGpu)
+ {
+ layer.Surface = SKSurface.Create(grContext!, budgeted: true, imageInfo);
+
+ // Fallback to CPU if GPU surface creation failed
+ if (layer.Surface is null)
+ {
+ if (LogRenderingMode)
+ {
+ logger.LogWarning(
+ "GPU surface creation failed, falling back to CPU for layer"
+ );
+ }
+ layer.Surface = SKSurface.Create(imageInfo);
+ }
+ else if (LogRenderingMode)
+ {
+ logger.LogDebug("Created GPU-accelerated surface for layer");
+ }
+ }
+ else
+ {
+ layer.Surface = SKSurface.Create(imageInfo);
+ if (LogRenderingMode)
+ {
+ logger.LogDebug("Created CPU surface for layer (GPU not available or disabled)");
+ }
+ }
+ }
+ else
+ {
+ // No resize needed, just clear
+ layer.Surface!.Canvas.Clear(SKColors.Transparent);
+ }
+ }
+ }
+
+ // Render all layer images in order
+ foreach (var (layerName, layer) in Layers)
+ {
+ // Skip background image if not requested
+ if (!renderBackgroundImage && layerName == "Background")
+ {
+ continue;
+ }
+
+ lock (layer)
+ {
+ var layerCanvas = layer.Surface!.Canvas;
+ foreach (var bitmap in layer.Bitmaps)
+ {
+ layerCanvas.DrawBitmap(bitmap, new SKPoint(0, 0));
+ }
+ }
+ }
+
+ // Render paint layer with caching optimization
+ RenderPathsWithCaching(BrushLayer.Surface!.Canvas);
+
+ // Draw background - either checkerboard for transparency or clear
+ // Draw background - either checkerboard for transparency or clear
+ // Include check for renderBackgroundFill so snapshots (like FloodFill analysis) can skip the checkerboard pattern
+ if (ShowCheckerboardBackground && renderBackgroundFill)
+ {
+ RenderCheckerboardBackground(surface.Canvas);
+ }
+ else
+ {
+ surface.Canvas.Clear(SKColors.Transparent);
+ }
+
+ // Draw the layers to the main surface
+ foreach (var layer in Layers.Values)
+ {
+ lock (layer)
+ {
+ layer.Surface!.Canvas.Flush();
+ surface.Canvas.DrawSurface(layer.Surface!, new SKPoint(0, 0));
+ }
+ }
+
+ // Draw grid overlay if enabled
+ if (ShowGridOverlay)
+ {
+ RenderGridOverlay(surface.Canvas);
+ }
+
+ surface.Canvas.Flush();
+ }
+
+ ///
+ /// Renders a checkerboard pattern to indicate transparent areas.
+ /// Uses a cached shader for efficient repeated rendering.
+ ///
+ private void RenderCheckerboardBackground(SKCanvas canvas)
+ {
+ // Check if we need to create or recreate the shader
+ if (cachedCheckerboardShader is null || cachedCheckerboardSize != CanvasSize)
+ {
+ cachedCheckerboardShader?.Dispose();
+ cachedCheckerboardShader = CreateCheckerboardShader();
+ cachedCheckerboardSize = CanvasSize;
+ }
+
+ using var paint = new SKPaint();
+ paint.Shader = cachedCheckerboardShader;
+ paint.IsAntialias = false;
+
+ canvas.DrawRect(0, 0, CanvasSize.Width, CanvasSize.Height, paint);
+ }
+
+ ///
+ /// Creates a checkerboard pattern shader using a small tiled bitmap.
+ ///
+ private static SKShader CreateCheckerboardShader()
+ {
+ // Create a small 2x2 checker bitmap (in units of square size)
+ var tileSize = CheckerboardSquareSize * 2;
+ using var tileBitmap = new SKBitmap(tileSize, tileSize);
+ using var tileCanvas = new SKCanvas(tileBitmap);
+
+ // Draw the four squares
+ using var lightPaint = new SKPaint { Color = CheckerboardLight };
+ using var darkPaint = new SKPaint { Color = CheckerboardDark };
+
+ // Top-left and bottom-right are light
+ tileCanvas.DrawRect(0, 0, CheckerboardSquareSize, CheckerboardSquareSize, lightPaint);
+ tileCanvas.DrawRect(
+ CheckerboardSquareSize,
+ CheckerboardSquareSize,
+ CheckerboardSquareSize,
+ CheckerboardSquareSize,
+ lightPaint
+ );
+
+ // Top-right and bottom-left are dark
+ tileCanvas.DrawRect(
+ CheckerboardSquareSize,
+ 0,
+ CheckerboardSquareSize,
+ CheckerboardSquareSize,
+ darkPaint
+ );
+ tileCanvas.DrawRect(
+ 0,
+ CheckerboardSquareSize,
+ CheckerboardSquareSize,
+ CheckerboardSquareSize,
+ darkPaint
+ );
+
+ tileCanvas.Flush();
+
+ // Create a shader that tiles this bitmap
+ return SKShader.CreateBitmap(tileBitmap, SKShaderTileMode.Repeat, SKShaderTileMode.Repeat);
+ }
+
+ ///
+ /// Renders a grid overlay for alignment assistance (e.g., rule of thirds).
+ ///
+ private void RenderGridOverlay(SKCanvas canvas)
+ {
+ if (GridDivisions <= 1 || CanvasSize == Size.Empty)
+ return;
+
+ using var paint = new SKPaint
+ {
+ Color = GridLineColor,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 1f,
+ };
+
+ var width = CanvasSize.Width;
+ var height = CanvasSize.Height;
+
+ // Draw vertical lines
+ for (var i = 1; i < GridDivisions; i++)
+ {
+ var x = (float)(width * i) / GridDivisions;
+ canvas.DrawLine(x, 0, x, height, paint);
+ }
+
+ // Draw horizontal lines
+ for (var i = 1; i < GridDivisions; i++)
+ {
+ var y = (float)(height * i) / GridDivisions;
+ canvas.DrawLine(0, y, width, y, paint);
+ }
+ }
+
+ ///
+ /// Renders paths with caching optimization. Completed paths are cached
+ /// to avoid re-rendering them every frame.
+ ///
+ private void RenderPathsWithCaching(SKCanvas paintLayerCanvas)
+ {
+ var currentPathCount = Paths.Count;
+ var hasTemporaryPaths = !TemporaryPaths.IsEmpty;
+
+ // Check if we can use the cached image
+ if (cachedPathsImage != null && cachedPathsCount == currentPathCount && !hasTemporaryPaths)
+ {
+ // All paths are cached and no temporary paths - just draw the cached image
+ paintLayerCanvas.DrawImage(cachedPathsImage, new SKPoint(0, 0));
+ return;
+ }
+
+ // Check if we need to update the cache (new completed paths)
+ if (cachedPathsCount < currentPathCount && !hasTemporaryPaths)
+ {
+ // Render all completed paths to a new cached image
+ UpdatePathCache();
+
+ if (cachedPathsImage != null)
+ {
+ paintLayerCanvas.DrawImage(cachedPathsImage, new SKPoint(0, 0));
+ return;
+ }
+ }
+
+ // Fallback: render with partial caching
+ using var paint = new SKPaint();
+
+ // If we have a cache, draw it first
+ if (cachedPathsImage != null && cachedPathsCount > 0)
+ {
+ paintLayerCanvas.DrawImage(cachedPathsImage, new SKPoint(0, 0));
+
+ // Only render paths that aren't in the cache
+ for (var i = cachedPathsCount; i < currentPathCount; i++)
+ {
+ RenderPenPath(paintLayerCanvas, Paths[i], paint);
+ }
+ }
+ else
+ {
+ // No cache, render all paths
+ foreach (var penPath in Paths)
+ {
+ RenderPenPath(paintLayerCanvas, penPath, paint);
+ }
+ }
+
+ // Render temporary paths directly (the batched RenderPenPath is already optimized)
+ foreach (var penPath in TemporaryPaths.Values)
+ {
+ RenderPenPath(paintLayerCanvas, penPath, paint);
+ }
+ }
+
+ ///
+ /// Renders temporary paths with incremental caching for long strokes.
+ /// Only new points since last render are drawn, dramatically improving
+ /// performance for continuous drawing.
+ ///
+ private void RenderTemporaryPathsIncremental(SKCanvas targetCanvas, SKPaint paint)
+ {
+ if (TemporaryPaths.IsEmpty)
+ {
+ // No temporary paths - dispose surface if exists
+ if (tempPathSurface != null)
+ {
+ tempPathSurface.Dispose();
+ tempPathSurface = null;
+ tempPathRenderedPoints.Clear();
+ }
+ return;
+ }
+
+ // For simplicity and reliability, use a hybrid approach:
+ // - Keep a cached surface for the "already rendered" portions
+ // - Render new points directly to target canvas (which gets composited)
+
+ // Ensure we have a temp surface
+ var needNewSurface = tempPathSurface == null;
+ if (!needNewSurface)
+ {
+ var bounds = tempPathSurface!.Canvas.DeviceClipBounds;
+ needNewSurface = bounds.Width != CanvasSize.Width || bounds.Height != CanvasSize.Height;
+ }
+
+ if (needNewSurface)
+ {
+ tempPathSurface?.Dispose();
+ var imageInfo = new SKImageInfo(CanvasSize.Width, CanvasSize.Height);
+
+ // Use CPU surface for temp paths to avoid GPU context threading issues
+ tempPathSurface = SKSurface.Create(imageInfo);
+ tempPathSurface?.Canvas.Clear(SKColors.Transparent);
+ tempPathRenderedPoints.Clear();
+ }
+
+ if (tempPathSurface == null)
+ {
+ // Fallback: render all temp paths directly
+ foreach (var penPath in TemporaryPaths.Values)
+ {
+ RenderPenPath(targetCanvas, penPath, paint);
+ }
+ return;
+ }
+
+ var tempCanvas = tempPathSurface.Canvas;
+
+ // Check if any paths were removed (stroke finalized) - need to clear and rebuild
+ var pathsRemoved = false;
+ foreach (var pointerId in tempPathRenderedPoints.Keys.ToArray())
+ {
+ if (!TemporaryPaths.ContainsKey(pointerId))
+ {
+ pathsRemoved = true;
+ tempPathRenderedPoints.TryRemove(pointerId, out _);
+ }
+ }
+
+ if (pathsRemoved)
+ {
+ // A stroke was finalized - clear the temp surface
+ tempCanvas.Clear(SKColors.Transparent);
+ tempPathRenderedPoints.Clear();
+ }
+
+ // Render each temporary path
+ foreach (var (pointerId, penPath) in TemporaryPaths)
+ {
+ var renderedCount = tempPathRenderedPoints.GetValueOrDefault(pointerId, 0);
+ var totalPoints = penPath.Points.Count;
+
+ if (totalPoints > renderedCount)
+ {
+ if (renderedCount == 0)
+ {
+ // New path - render everything to the temp surface
+ RenderPenPath(tempCanvas, penPath, paint);
+ }
+ else
+ {
+ // Continuing path - render new segment to temp surface
+ RenderPenPathSegment(tempCanvas, penPath, renderedCount, totalPoints, paint);
+ }
+ tempPathRenderedPoints[pointerId] = totalPoints;
+ }
+ }
+
+ // Draw the temp surface to target
+ tempCanvas.Flush();
+ using var tempImage = tempPathSurface.Snapshot();
+ targetCanvas.DrawImage(tempImage, new SKPoint(0, 0));
+ }
+
+ ///
+ /// Renders a segment of a pen path (from startIndex to endIndex).
+ /// Used for incremental rendering of temporary paths.
+ ///
+ private static void RenderPenPathSegment(
+ SKCanvas canvas,
+ PenPath penPath,
+ int startIndex,
+ int endIndex,
+ SKPaint paint
+ )
+ {
+ if (startIndex >= endIndex || penPath.Points.Count == 0)
+ return;
// Apply Color
if (penPath.IsErase)
{
- // paint.BlendMode = SKBlendMode.SrcIn;
paint.BlendMode = SKBlendMode.Clear;
paint.Color = SKColors.Transparent;
}
@@ -308,61 +1553,467 @@ private static void RenderPenPath(SKCanvas canvas, PenPath penPath, SKPaint pain
paint.Color = penPath.FillColor;
}
- // Defaults
paint.IsDither = true;
paint.IsAntialias = true;
+ paint.Style = SKPaintStyle.Stroke;
+ paint.StrokeCap = SKStrokeCap.Round;
+ paint.StrokeJoin = SKStrokeJoin.Round;
+
+ // Apply feathering (soft brush edge) using blur mask filter
+ if (penPath.Feathering > 0)
+ {
+ var effectiveRadiusForBlur = penPath.GetEffectiveRadius();
+ var blurSigma = effectiveRadiusForBlur * penPath.Feathering * 0.5f;
+ if (blurSigma > 0.1f)
+ {
+ paint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, blurSigma);
+ }
+ }
+ else
+ {
+ paint.MaskFilter = null;
+ }
- // Track if we have any pen points
- var hasPenPoints = false;
+ using var path = new SKPath();
+ var started = false;
+ var currentThickness = 0f;
- // Can't use foreach since this list may be modified during iteration
- // ReSharper disable once ForCanBeConvertedToForeach
- for (var i = 0; i < penPath.Points.Count; i++)
+ // Start from one point before to ensure continuity
+ var actualStart = Math.Max(0, startIndex - 1);
+
+ var effectiveRadius = penPath.GetEffectiveRadius();
+
+ for (var i = actualStart; i < endIndex && i < penPath.Points.Count; i++)
{
- var penPoint = penPath.Points[i];
+ var point = penPath.Points[i];
+ if (!point.IsPen)
+ continue;
- // Skip non-pen points
- if (!penPoint.IsPen)
+ var thickness = (float)((point.Pressure ?? 1) * effectiveRadius * 2.5);
+
+ if (!started)
{
- continue;
+ path.MoveTo(point.X, point.Y);
+ currentThickness = thickness;
+ started = true;
+ }
+ else
+ {
+ path.LineTo(point.X, point.Y);
+ currentThickness = (currentThickness + thickness) / 2;
}
+ }
+
+ if (started)
+ {
+ paint.StrokeWidth = currentThickness;
+ canvas.DrawPath(path, paint);
+ }
+ }
+
+ ///
+ /// Clears the temporary path cache. Call when a stroke is finalized.
+ ///
+ public void ClearTempPathCache()
+ {
+ tempPathSurface?.Dispose();
+ tempPathSurface = null;
+ tempPathRenderedPoints.Clear();
+ }
+
+ ///
+ /// Updates the path cache with all current completed paths.
+ /// Uses CPU-only surfaces to avoid GPU context threading issues.
+ ///
+ private void UpdatePathCache()
+ {
+ if (CanvasSize == Size.Empty || Paths.Count == 0)
+ {
+ cachedPathsImage?.Dispose();
+ cachedPathsImage = null;
+ cachedPathsCount = 0;
+ return;
+ }
+
+ var imageInfo = new SKImageInfo(CanvasSize.Width, CanvasSize.Height);
+
+ // Always use CPU surface for cache to avoid GPU context threading issues
+ // The cache is created once per set of completed paths, so CPU performance is acceptable
+ var cacheSurface = SKSurface.Create(imageInfo);
- hasPenPoints = true;
+ if (cacheSurface == null)
+ {
+ logger.LogWarning("Failed to create cache surface");
+ return;
+ }
+
+ using (cacheSurface)
+ {
+ var cacheCanvas = cacheSurface.Canvas;
+ cacheCanvas.Clear(SKColors.Transparent);
- var radius = penPoint.Radius;
- var pressure = penPoint.Pressure ?? 1;
- var thickness = pressure * radius * 2.5;
+ using var paint = new SKPaint();
- // Draw path
- if (i < penPath.Points.Count - 1)
+ // Render all completed paths
+ foreach (var penPath in Paths)
{
- paint.Style = SKPaintStyle.Stroke;
- paint.StrokeWidth = (float)thickness;
- paint.StrokeCap = SKStrokeCap.Round;
- paint.StrokeJoin = SKStrokeJoin.Round;
+ RenderPenPath(cacheCanvas, penPath, paint);
+ }
- var nextPoint = penPath.Points[i + 1];
- canvas.DrawLine(penPoint.X, penPoint.Y, nextPoint.X, nextPoint.Y, paint);
+ // Save the cached image
+ cachedPathsImage?.Dispose();
+ cachedPathsImage = cacheSurface.Snapshot();
+ cachedPathsCount = Paths.Count;
+
+ if (LogRenderingMode)
+ {
+ logger.LogDebug("Updated path cache with {Count} paths (CPU surface)", cachedPathsCount);
}
+ }
+ }
- // Draw circles for pens
- paint.Style = SKPaintStyle.Fill;
- canvas.DrawCircle(penPoint.X, penPoint.Y, (float)thickness / 2, paint);
+ ///
+ /// Renders a pen path to a canvas. This method is public so it can be shared
+ /// with other ViewModels like LayeredMaskEditorViewModel.
+ /// Optimized to batch draw calls into a single SKPath for performance.
+ ///
+ /// If provided, uses this color instead of the path's FillColor. Useful for mask export.
+ public static void RenderPenPath(
+ SKCanvas canvas,
+ PenPath penPath,
+ SKPaint paint,
+ SKColor? overrideColor = null
+ )
+ {
+ // Handle shape path types (Rectangle, Ellipse, Bitmap)
+ switch (penPath.PathType)
+ {
+ case PenPathType.Rectangle:
+ case PenPathType.Ellipse:
+ RenderShapePath(canvas, penPath, paint, overrideColor);
+ return;
+
+ case PenPathType.Bitmap:
+ RenderBitmapPath(canvas, penPath, paint, overrideColor);
+ return;
+
+ case PenPathType.Freehand:
+ default:
+ // Continue with freehand rendering below
+ RenderFreehandPath(canvas, penPath, paint, overrideColor);
+ return;
}
+ }
- // Draw paths directly if we didn't have any pen points
- if (!hasPenPoints)
+ ///
+ /// Renders shape paths (Rectangle and Ellipse) to the canvas.
+ ///
+ private static void RenderShapePath(
+ SKCanvas canvas,
+ PenPath penPath,
+ SKPaint paint,
+ SKColor? overrideColor
+ )
+ {
+ // Apply color and blend mode
+ if (penPath.IsErase)
+ {
+ paint.BlendMode = SKBlendMode.Clear;
+ paint.Color = SKColors.Transparent;
+ }
+ else
{
- var point = penPath.Points[0];
- var thickness = point.Radius * 2;
+ paint.BlendMode = SKBlendMode.SrcOver;
+ paint.Color = overrideColor ?? penPath.FillColor;
+ }
+
+ paint.IsDither = true;
+ paint.IsAntialias = true;
+ if (penPath.IsStrokeOnly)
+ {
paint.Style = SKPaintStyle.Stroke;
- paint.StrokeWidth = (float)thickness;
- paint.StrokeCap = SKStrokeCap.Round;
- paint.StrokeJoin = SKStrokeJoin.Round;
+ paint.StrokeWidth = penPath.StrokeWidth;
+ }
+ else
+ {
+ paint.Style = SKPaintStyle.Fill;
+ }
+
+ if (penPath.PathType == PenPathType.Rectangle)
+ {
+ canvas.DrawRect(penPath.Bounds, paint);
+ }
+ else // Ellipse
+ {
+ canvas.DrawOval(penPath.Bounds, paint);
+ }
+ }
+
+ ///
+ /// Renders bitmap paths to the canvas with optional color override.
+ ///
+ private static void RenderBitmapPath(
+ SKCanvas canvas,
+ PenPath penPath,
+ SKPaint paint,
+ SKColor? overrideColor
+ )
+ {
+ if (penPath.BitmapData == null)
+ return;
+
+ if (overrideColor.HasValue)
+ {
+ // Apply color filter to replace colors with override while keeping alpha
+ var color = overrideColor.Value;
+ using var colorPaint = new SKPaint();
+ // Color matrix that replaces RGB with override color, preserves alpha
+ // csharpier-ignore
+ colorPaint.ColorFilter = SKColorFilter.CreateColorMatrix(
+ [
+ 0, 0, 0, 0, color.Red / 255f,
+ 0, 0, 0, 0, color.Green / 255f,
+ 0, 0, 0, 0, color.Blue / 255f,
+ 0, 0, 0, 1, 0
+ ]);
+ canvas.DrawBitmap(penPath.BitmapData, penPath.Bounds.Left, penPath.Bounds.Top, colorPaint);
+ }
+ else
+ {
+ canvas.DrawBitmap(penPath.BitmapData, penPath.Bounds.Left, penPath.Bounds.Top);
+ }
+ }
+
+ ///
+ /// Renders freehand paths with pressure-sensitive strokes to the canvas.
+ ///
+ private static void RenderFreehandPath(
+ SKCanvas canvas,
+ PenPath penPath,
+ SKPaint paint,
+ SKColor? overrideColor = null
+ )
+ {
+ // Freehand path rendering
+ if (penPath.Points.Count == 0)
+ {
+ return;
+ }
+
+ // Apply Color
+ if (penPath.IsErase)
+ {
+ paint.BlendMode = SKBlendMode.Clear;
+ paint.Color = SKColors.Transparent;
+ }
+ else
+ {
+ paint.BlendMode = SKBlendMode.SrcOver;
+ paint.Color = overrideColor ?? penPath.FillColor;
+ }
+
+ // Setup paint for strokes
+ paint.IsDither = true;
+ paint.IsAntialias = true;
+ paint.Style = SKPaintStyle.Stroke;
+ paint.StrokeCap = SKStrokeCap.Round; // Round caps handle endpoints
+ paint.StrokeJoin = SKStrokeJoin.Round;
+
+ // Apply feathering (soft brush edge) using blur mask filter
+ if (penPath.Feathering > 0)
+ {
+ // Calculate blur sigma based on the effective radius and feathering amount
+ var effectiveRadiusForBlur = penPath.GetEffectiveRadius();
+ var blurSigma = effectiveRadiusForBlur * penPath.Feathering * 0.5f;
+ if (blurSigma > 0.1f)
+ {
+ paint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, blurSigma);
+ }
+ }
+ else
+ {
+ paint.MaskFilter = null;
+ }
+
+ // Count pen points and check pressure uniformity in a single pass (avoids LINQ allocations)
+ var penPointCount = 0;
+ var uniformPressure = true;
+ var firstPressure = 0.0;
+ var totalThickness = 0.0;
+ var firstPenPointIndex = -1;
+
+ // Get effective radius (path-level or backward-compat from first point)
+ var effectiveRadius = penPath.GetEffectiveRadius();
+
+ for (var i = 0; i < penPath.Points.Count; i++)
+ {
+ var p = penPath.Points[i];
+ if (!p.IsPen)
+ continue;
+
+ var pressure = p.Pressure ?? 1;
+ var thickness = pressure * effectiveRadius * 2.5;
+
+ if (penPointCount == 0)
+ {
+ firstPressure = pressure;
+ firstPenPointIndex = i;
+ }
+ else if (uniformPressure && Math.Abs(pressure - firstPressure) >= 0.1)
+ {
+ uniformPressure = false;
+ }
+
+ totalThickness += thickness;
+ penPointCount++;
+ }
+ if (penPointCount == 0)
+ {
+ // No pen points - use the ToSKPath method for mouse-based paths
+ paint.StrokeWidth = effectiveRadius * 2;
var skPath = penPath.ToSKPath();
canvas.DrawPath(skPath, paint);
+ return;
+ }
+
+ // For pressure-sensitive drawing, we need to handle variable thickness
+ if (penPointCount == 1)
+ {
+ // Single point - draw a circle
+ var point = penPath.Points[firstPenPointIndex];
+ var thickness = (point.Pressure ?? 1) * effectiveRadius * 2.5;
+ paint.Style = SKPaintStyle.Fill;
+ canvas.DrawCircle(point.X, point.Y, (float)(thickness / 2), paint);
+ return;
+ }
+
+ if (uniformPressure)
+ {
+ // All points have similar pressure - batch into single path
+ var avgThickness = totalThickness / penPointCount;
+ paint.StrokeWidth = (float)avgThickness;
+
+ using var path = new SKPath();
+ var started = false;
+
+ // Use plain loop instead of LINQ to avoid iterator allocation in hot path
+ foreach (var p in penPath.Points)
+ {
+ if (!p.IsPen)
+ continue;
+
+ if (!started)
+ {
+ path.MoveTo(p.X, p.Y);
+ started = true;
+ }
+ else
+ {
+ path.LineTo(p.X, p.Y);
+ }
+ }
+
+ canvas.DrawPath(path, paint);
+ }
+ else
+ {
+ // Variable pressure - draw segments with varying thickness
+ // Batch into groups of similar thickness for fewer draw calls
+ using var path = new SKPath();
+ var currentThickness = 0f;
+ var pathStarted = false;
+ var lastPenX = 0f;
+ var lastPenY = 0f;
+
+ foreach (var point in penPath.Points)
+ {
+ if (!point.IsPen)
+ continue;
+
+ var thickness = (float)((point.Pressure ?? 1) * effectiveRadius * 2.5);
+
+ // If thickness changed significantly, draw current path and start new one
+ if (pathStarted && Math.Abs(thickness - currentThickness) > currentThickness * 0.2f)
+ {
+ paint.StrokeWidth = currentThickness;
+ canvas.DrawPath(path, paint);
+ path.Reset();
+
+ // Start new path from previous point for continuity
+ path.MoveTo(lastPenX, lastPenY);
+ pathStarted = false;
+ }
+
+ if (!pathStarted)
+ {
+ path.MoveTo(point.X, point.Y);
+ currentThickness = thickness;
+ pathStarted = true;
+ }
+ else
+ {
+ path.LineTo(point.X, point.Y);
+ // Smoothly blend thickness
+ currentThickness = (currentThickness + thickness) / 2;
+ }
+
+ lastPenX = point.X;
+ lastPenY = point.Y;
+ }
+
+ // Draw remaining path
+ if (pathStarted)
+ {
+ paint.StrokeWidth = currentThickness;
+ canvas.DrawPath(path, paint);
+ }
+ }
+ }
+
+ ///
+ /// Disposes all cached resources to free memory.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+
+ // Dispose cached path image
+ cachedPathsImage?.Dispose();
+ cachedPathsImage = null;
+
+ // Dispose temporary path surface
+ tempPathSurface?.Dispose();
+ tempPathSurface = null;
+ tempPathRenderedPoints.Clear();
+
+ // Dispose checkerboard shader
+ cachedCheckerboardShader?.Dispose();
+ cachedCheckerboardShader = null;
+
+ // Dispose layer surfaces and bitmaps
+ foreach (var layer in Layers.Values)
+ {
+ lock (layer)
+ {
+ layer.Surface?.Dispose();
+ layer.Surface = null;
+
+ foreach (var bitmap in layer.Bitmaps)
+ {
+ bitmap.Dispose();
+ }
+ layer.Bitmaps = [];
+ }
}
+
+ // Clear paths
+ TemporaryPaths.Clear();
+
+ GC.SuppressFinalize(this);
}
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/DownloadMissingModelsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/DownloadMissingModelsViewModel.cs
new file mode 100644
index 000000000..8e1c9fafd
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/DownloadMissingModelsViewModel.cs
@@ -0,0 +1,271 @@
+using System.Collections.ObjectModel;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using FluentAvalonia.UI.Controls;
+using Injectio.Attributes;
+using Microsoft.Extensions.Logging;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Languages;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.Views.Dialogs;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Extensions;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Models.Progress;
+using StabilityMatrix.Core.Services;
+
+namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
+
+///
+/// Reusable dialog view model for downloading missing models.
+/// Can be configured for any provider that needs model downloads.
+///
+[View(typeof(DownloadMissingModelsDialog))]
+[ManagedService]
+[RegisterTransient]
+public partial class DownloadMissingModelsViewModel(
+ ILogger logger,
+ ISettingsManager settingsManager,
+ ITrackedDownloadService trackedDownloadService,
+ IDownloadService downloadService
+) : ContentDialogViewModelBase
+{
+ ///
+ /// Dialog title (e.g., "Flux Kontext Setup")
+ ///
+ [ObservableProperty]
+ public partial string DialogTitle { get; set; } = "Download Required Models";
+
+ ///
+ /// Friendly description message
+ ///
+ [ObservableProperty]
+ public partial string Description { get; set; } =
+ "The following models are required. Select the ones you'd like to download.";
+
+ ///
+ /// Collection of downloadable model items
+ ///
+ public ObservableCollection Models { get; } = [];
+
+ ///
+ /// Whether file sizes are being loaded
+ ///
+ [ObservableProperty]
+ public partial bool IsLoadingSizes { get; set; }
+
+ ///
+ /// Number of selected items
+ ///
+ public int SelectedCount => Models.Count(m => m.IsSelected);
+
+ ///
+ /// Total size of selected items
+ ///
+ public string TotalSelectedSizeText
+ {
+ get
+ {
+ var totalBytes = Models.Where(m => m.IsSelected).Sum(m => m.FileSize);
+ return totalBytes > 0 ? Size.FormatBase10Bytes(totalBytes) : "Calculating...";
+ }
+ }
+
+ ///
+ /// Whether download can be started
+ ///
+ public bool CanStartDownload => SelectedCount > 0;
+
+ ///
+ /// The downloads that were started (populated after StartDownloadsAsync is called)
+ ///
+ public List StartedDownloads { get; } = [];
+
+ ///
+ /// Set the models to display in the dialog
+ ///
+ public void SetModels(IEnumerable resources)
+ {
+ Models.Clear();
+
+ foreach (var resource in resources)
+ {
+ var item = new DownloadableModelItemViewModel(resource);
+ item.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(DownloadableModelItemViewModel.IsSelected))
+ {
+ OnPropertyChanged(nameof(SelectedCount));
+ OnPropertyChanged(nameof(TotalSelectedSizeText));
+ OnPropertyChanged(nameof(CanStartDownload));
+ }
+ };
+ Models.Add(item);
+ }
+
+ // Load file sizes asynchronously
+ _ = LoadFileSizesAsync();
+ }
+
+ private async Task LoadFileSizesAsync()
+ {
+ if (Design.IsDesignMode)
+ return;
+
+ IsLoadingSizes = true;
+
+ try
+ {
+ var tasks = Models.Select(async model =>
+ {
+ try
+ {
+ if (model.Resource.Url is { } url)
+ {
+ var size = await downloadService.GetFileSizeAsync(url.ToString());
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ model.FileSize = size;
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to get file size for {FileName}", model.FileName);
+ }
+ });
+
+ await Task.WhenAll(tasks);
+ }
+ finally
+ {
+ IsLoadingSizes = false;
+ OnPropertyChanged(nameof(TotalSelectedSizeText));
+ }
+ }
+
+ [RelayCommand]
+ private void SelectAll()
+ {
+ foreach (var model in Models)
+ {
+ model.IsSelected = true;
+ }
+ }
+
+ [RelayCommand]
+ private void DeselectAll()
+ {
+ foreach (var model in Models)
+ {
+ model.IsSelected = false;
+ }
+ }
+
+ ///
+ /// Queue downloads for all selected models. Returns the list of started downloads.
+ /// Call this after dialog closes with Primary result.
+ ///
+ public async Task> StartDownloadsAsync()
+ {
+ var selectedModels = Models.Where(m => m.IsSelected).ToList();
+ StartedDownloads.Clear();
+
+ if (selectedModels.Count == 0)
+ {
+ return StartedDownloads;
+ }
+
+ logger.LogInformation("Queueing download of {Count} models", selectedModels.Count);
+
+ foreach (var model in selectedModels)
+ {
+ try
+ {
+ var download = await QueueDownloadAsync(model);
+ if (download != null)
+ {
+ StartedDownloads.Add(download);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to queue download for {FileName}", model.FileName);
+ }
+ }
+
+ // Show progress flyout
+ if (StartedDownloads.Count > 0)
+ {
+ EventManager.Instance.OnToggleProgressFlyout();
+ }
+
+ return StartedDownloads;
+ }
+
+ private async Task QueueDownloadAsync(DownloadableModelItemViewModel model)
+ {
+ var resource = model.Resource;
+
+ var sharedFolderType =
+ resource.ContextType as SharedFolderType?
+ ?? throw new InvalidOperationException(
+ $"ContextType is not SharedFolderType for {resource.FileName}"
+ );
+
+ var modelsDir = new DirectoryPath(settingsManager.ModelsDirectory).JoinDir(
+ sharedFolderType.GetStringValue()
+ );
+
+ if (resource.RelativeDirectory is not null)
+ {
+ modelsDir = modelsDir.JoinDir(resource.RelativeDirectory);
+ }
+
+ // Ensure directory exists
+ modelsDir.Create();
+
+ var downloadPath = modelsDir.JoinFile(resource.FileName);
+
+ logger.LogInformation("Queueing download: {FileName} to {Path}", resource.FileName, downloadPath);
+
+ var download = trackedDownloadService.NewDownload(resource.Url, downloadPath);
+
+ // Set hash for verification if available
+ if (resource.HashSha256 is not null)
+ {
+ download.ExpectedHashSha256 = resource.HashSha256;
+ }
+
+ // Set extraction properties
+ download.AutoExtractArchive = resource.AutoExtractArchive;
+ download.ExtractRelativePath = resource.ExtractRelativePath;
+
+ // Set context action for post-download processing
+ download.ContextAction = new ModelPostDownloadContextAction();
+
+ // Start the download
+ await trackedDownloadService.TryStartDownload(download);
+
+ return download;
+ }
+
+ public override BetterContentDialog GetDialog()
+ {
+ var dialog = base.GetDialog();
+
+ dialog.Title = DialogTitle;
+ dialog.Content = new DownloadMissingModelsDialog { DataContext = this };
+ dialog.PrimaryButtonText = Resources.Action_Download;
+ dialog.CloseButtonText = "Skip for Now";
+ dialog.DefaultButton = ContentDialogButton.Primary;
+ dialog.IsPrimaryButtonEnabled = CanStartDownload;
+ dialog.MinDialogWidth = 550;
+
+ return dialog;
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/DownloadableModelItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/DownloadableModelItemViewModel.cs
new file mode 100644
index 000000000..265806722
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/DownloadableModelItemViewModel.cs
@@ -0,0 +1,140 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Progress;
+
+namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
+
+///
+/// ViewModel for a single downloadable model item in the missing models dialog.
+/// Wraps a RemoteResource with selection and progress state.
+///
+public partial class DownloadableModelItemViewModel(RemoteResource resource, string? displayName = null)
+ : ViewModelBase
+{
+ ///
+ /// The underlying remote resource
+ ///
+ public RemoteResource Resource { get; } = resource;
+
+ ///
+ /// Whether this item is selected for download
+ ///
+ [ObservableProperty]
+ public partial bool IsSelected { get; set; } = true;
+
+ ///
+ /// Whether this item is currently downloading
+ ///
+ [ObservableProperty]
+ public partial bool IsDownloading { get; set; }
+
+ ///
+ /// Whether this item has completed downloading
+ ///
+ [ObservableProperty]
+ public partial bool IsCompleted { get; set; }
+
+ ///
+ /// Whether this item failed to download
+ ///
+ [ObservableProperty]
+ public partial bool IsFailed { get; set; }
+
+ ///
+ /// Current download progress (0-100)
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ProgressText))]
+ public partial double Progress { get; set; }
+
+ ///
+ /// File size in bytes (fetched asynchronously)
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(FileSizeText))]
+ public partial long FileSize { get; set; }
+
+ ///
+ /// Status message for the download
+ ///
+ [ObservableProperty]
+ public partial string? StatusMessage { get; set; }
+
+ ///
+ /// Display name for the model
+ ///
+ public string DisplayName { get; } = displayName ?? GetDefaultDisplayName(resource);
+
+ ///
+ /// Type badge text (e.g., "UNET", "VAE", "CLIP")
+ ///
+ public string TypeBadge { get; } = GetTypeBadge(resource);
+
+ ///
+ /// File name
+ ///
+ public string FileName => Resource.FileName;
+
+ ///
+ /// Formatted file size text
+ ///
+ public string? FileSizeText => FileSize > 0 ? Size.FormatBase10Bytes(FileSize) : null;
+
+ ///
+ /// Progress text for display
+ ///
+ public string ProgressText => IsDownloading ? $"{Progress:F0}%" : string.Empty;
+
+ ///
+ /// Author of the model
+ ///
+ public string? Author => Resource.Author;
+
+ ///
+ /// License type
+ ///
+ public string? LicenseType => Resource.LicenseType;
+
+ // Determine display name based on context type or filename
+
+ private static string GetDefaultDisplayName(RemoteResource resource)
+ {
+ // Try to get a friendly name based on the file and context
+ var fileName = resource.FileName;
+
+ return resource.ContextType switch
+ {
+ SharedFolderType.DiffusionModels
+ when fileName.Contains("kontext", StringComparison.OrdinalIgnoreCase) => "Flux Kontext UNET",
+ SharedFolderType.VAE when fileName.Equals("ae.safetensors", StringComparison.OrdinalIgnoreCase) =>
+ "Flux VAE",
+ SharedFolderType.TextEncoders
+ when fileName.Contains("clip_l", StringComparison.OrdinalIgnoreCase) => "CLIP-L Text Encoder",
+ SharedFolderType.TextEncoders
+ when fileName.Contains("t5xxl", StringComparison.OrdinalIgnoreCase) => "T5-XXL Text Encoder",
+ _ => Path.GetFileNameWithoutExtension(fileName),
+ };
+ }
+
+ private static string GetTypeBadge(RemoteResource resource)
+ {
+ return resource.ContextType switch
+ {
+ SharedFolderType.DiffusionModels => "UNET",
+ SharedFolderType.VAE => "VAE",
+ SharedFolderType.TextEncoders => "CLIP",
+ SharedFolderType.ControlNet => "ControlNet",
+ SharedFolderType.Lora or SharedFolderType.LyCORIS => "LoRA",
+ _ => resource.ContextType?.ToString() ?? "Model",
+ };
+ }
+
+ [RelayCommand]
+ private void ToggleSelection()
+ {
+ IsSelected = !IsSelected;
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ImageAnnotationEditorViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ImageAnnotationEditorViewModel.cs
new file mode 100644
index 000000000..e4b99435d
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ImageAnnotationEditorViewModel.cs
@@ -0,0 +1,222 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json.Serialization;
+using Avalonia;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Injectio.Attributes;
+using SkiaSharp;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Extensions;
+using StabilityMatrix.Avalonia.Languages;
+using StabilityMatrix.Avalonia.Models;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.Controls;
+using StabilityMatrix.Avalonia.Views.Dialogs;
+using StabilityMatrix.Core.Attributes;
+using ContentDialogButton = FluentAvalonia.UI.Controls.ContentDialogButton;
+
+namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
+
+///
+/// ViewModel for the image annotation editor dialog.
+/// Allows users to draw/annotate on images before sending to AI providers.
+///
+[RegisterTransient]
+[ManagedService]
+[View(typeof(ImageAnnotationEditorDialog))]
+public partial class ImageAnnotationEditorViewModel(IServiceManager vmFactory)
+ : LoadableViewModelBase,
+ IDisposable
+{
+ [JsonIgnore]
+ private SKBitmap? originalBitmap;
+
+ [JsonIgnore]
+ private ImageSource? cachedAnnotatedImage;
+
+ ///
+ /// The source image file path being edited
+ ///
+ [ObservableProperty]
+ private string? sourceFilePath;
+
+ ///
+ /// The paint canvas view model for drawing annotations
+ ///
+ [JsonInclude]
+ public PaintCanvasViewModel PaintCanvasViewModel { get; } = vmFactory.Get();
+
+ ///
+ /// Whether there are any annotations on the canvas
+ ///
+ public bool HasAnnotations => PaintCanvasViewModel.Paths.Count > 0;
+
+ ///
+ /// Load an image from file path for editing
+ ///
+ public void LoadImage(string filePath)
+ {
+ SourceFilePath = filePath;
+ originalBitmap?.Dispose();
+ originalBitmap = SKBitmap.Decode(filePath);
+
+ if (originalBitmap != null)
+ {
+ PaintCanvasViewModel.BackgroundImage = originalBitmap;
+ PaintCanvasViewModel.RefreshCanvas?.Invoke();
+ }
+ }
+
+ ///
+ /// Load an image from bitmap for editing
+ ///
+ public void LoadImage(Bitmap bitmap, string? sourcePath = null)
+ {
+ SourceFilePath = sourcePath;
+ originalBitmap?.Dispose();
+
+ // Convert Avalonia Bitmap to SKBitmap
+ using var stream = new MemoryStream();
+ bitmap.Save(stream);
+ stream.Position = 0;
+ originalBitmap = SKBitmap.Decode(stream);
+
+ if (originalBitmap != null)
+ {
+ PaintCanvasViewModel.BackgroundImage = originalBitmap;
+ PaintCanvasViewModel.RefreshCanvas?.Invoke();
+ }
+ }
+
+ ///
+ /// Get the annotated image with drawings overlaid on the original
+ ///
+ [MethodImpl(MethodImplOptions.Synchronized)]
+ public ImageSource? GetAnnotatedImage()
+ {
+ if (cachedAnnotatedImage != null)
+ {
+ return cachedAnnotatedImage;
+ }
+
+ using var skImage = RenderAnnotatedImage();
+ if (skImage == null)
+ {
+ return null;
+ }
+
+ cachedAnnotatedImage = new ImageSource(skImage.ToAvaloniaBitmap());
+ return cachedAnnotatedImage;
+ }
+
+ ///
+ /// Render the annotated image to an SKImage
+ ///
+ public SKImage? RenderAnnotatedImage()
+ {
+ var canvasSize = PaintCanvasViewModel.CanvasSize;
+ if (canvasSize.IsEmpty)
+ {
+ return null;
+ }
+
+ using var surface = SKSurface.Create(new SKImageInfo(canvasSize.Width, canvasSize.Height));
+ PaintCanvasViewModel.RenderToSurface(
+ surface,
+ renderBackgroundFill: false,
+ renderBackgroundImage: true
+ );
+
+ return surface.Snapshot();
+ }
+
+ ///
+ /// Save the annotated image to a file
+ ///
+ public async Task SaveAnnotatedImageAsync(string? targetPath = null)
+ {
+ using var image = RenderAnnotatedImage();
+ if (image == null)
+ {
+ return null;
+ }
+
+ // Generate target path if not provided
+ targetPath ??= Path.Combine(Path.GetTempPath(), $"annotated_{Guid.NewGuid():N}.png");
+
+ using var data = image.Encode(SKEncodedImageFormat.Png, 100);
+ await using var fileStream = File.OpenWrite(targetPath);
+ data.SaveTo(fileStream);
+
+ return targetPath;
+ }
+
+ ///
+ /// Get the annotated image as a byte array (PNG format)
+ ///
+ public byte[]? GetAnnotatedImageBytes()
+ {
+ using var image = RenderAnnotatedImage();
+ if (image == null)
+ {
+ return null;
+ }
+
+ using var data = image.Encode(SKEncodedImageFormat.Png, 100);
+ return data.ToArray();
+ }
+
+ ///
+ /// Invalidate the cached annotated image
+ ///
+ public void InvalidateCache()
+ {
+ cachedAnnotatedImage?.Dispose();
+ cachedAnnotatedImage = null;
+ }
+
+ ///
+ /// Clear all annotations from the canvas
+ ///
+ [RelayCommand]
+ public void ClearAnnotations()
+ {
+ PaintCanvasViewModel.Paths = [];
+ PaintCanvasViewModel.RefreshCanvas?.Invoke();
+ InvalidateCache();
+ }
+
+ ///
+ /// Create and show the editor dialog
+ ///
+ public BetterContentDialog GetDialog()
+ {
+ Dispatcher.UIThread.VerifyAccess();
+
+ var dialog = new BetterContentDialog
+ {
+ Content = this,
+ ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ MaxDialogHeight = 900,
+ MaxDialogWidth = 1200,
+ ContentMargin = new Thickness(16),
+ FullSizeDesired = true,
+ PrimaryButtonText = Resources.Action_Save,
+ CloseButtonText = Resources.Action_Cancel,
+ DefaultButton = ContentDialogButton.Primary,
+ };
+
+ return dialog;
+ }
+
+ public void Dispose()
+ {
+ originalBitmap?.Dispose();
+ cachedAnnotatedImage?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/LayeredMaskEditorViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/LayeredMaskEditorViewModel.cs
new file mode 100644
index 000000000..b62a1f104
--- /dev/null
+++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/LayeredMaskEditorViewModel.cs
@@ -0,0 +1,1907 @@
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Text.Json.Nodes;
+using Avalonia;
+using Avalonia.Controls.Primitives;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DynamicData;
+using DynamicData.Binding;
+using Injectio.Attributes;
+using Microsoft.Extensions.Logging;
+using SkiaSharp;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.Controls.Models;
+using StabilityMatrix.Avalonia.Languages;
+using StabilityMatrix.Avalonia.Models.Inference;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.ViewModels.Controls;
+using StabilityMatrix.Avalonia.Views.Dialogs;
+using StabilityMatrix.Core.Attributes;
+using StabilityMatrix.Core.Models.Database;
+using StabilityMatrix.Core.Services;
+using ContentDialogButton = FluentAvalonia.UI.Controls.ContentDialogButton;
+using Size = System.Drawing.Size;
+
+namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
+
+///
+/// ViewModel for the layered mask editor dialog.
+/// Manages multiple layers with independent masks, prompts, and opacity settings.
+///
+[RegisterTransient]
+[ManagedService]
+[View(typeof(LayeredMaskEditorDialog))]
+public partial class LayeredMaskEditorViewModel : LoadableViewModelBase, IDisposable
+{
+ private readonly IImageIndexService imageIndexService;
+ private readonly ILogger logger;
+ private readonly IServiceManager vmFactory;
+
+ ///
+ /// Canvas size for all layers.
+ ///
+ [ObservableProperty]
+ private Size canvasSize = new(1024, 1024);
+
+ ///
+ /// Previous canvas size, used for rescaling layers when dimensions change.
+ ///
+ private Size _previousCanvasSize = new(1024, 1024);
+
+ private int imageLayerCounter;
+
+ ///
+ /// Stack of layer snapshots for undo support.
+ /// Each entry captures the full layer state before a destructive operation.
+ ///
+ private readonly Stack layerUndoStack = new();
+
+ ///
+ /// Maximum number of undo snapshots to keep.
+ ///
+ private const int MaxUndoSnapshots = 20;
+
+ ///
+ /// Whether the recent images panel is expanded.
+ ///
+ [ObservableProperty]
+ private bool isRecentImagesPanelExpanded;
+
+ ///
+ /// Counter to suppress layer index change callbacks from programmatic list updates.
+ /// This keeps drag-drop callbacks from fighting keyboard and button reorders.
+ ///
+ private int layerIndexChangeSuppressionCount;
+
+ private int layerCounter;
+
+ ///
+ /// Cached bitmap for the currently selected image layer.
+ /// Invalidated when source image, scale, opacity, offset, flip, or canvas size changes.
+ ///
+ private SKBitmap? _cachedImageLayerBitmap;
+ private MaskLayer? _cachedImageLayerSource;
+ private SKBitmap? _cachedImageLayerSourceImage;
+ private double _cachedImageLayerScale;
+ private double _cachedImageLayerOpacity;
+ private double _cachedImageLayerOffsetX;
+ private double _cachedImageLayerOffsetY;
+ private bool _cachedImageLayerFlipH;
+ private bool _cachedImageLayerFlipV;
+ private Size _cachedImageLayerCanvasSize;
+
+ ///
+ /// The currently selected layer for editing.
+ ///
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DeleteLayerCommand))]
+ private MaskLayer? selectedLayer;
+
+ ///
+ /// When true, shows all layers composited on the canvas.
+ /// When false, shows only the selected layer.
+ ///
+ [ObservableProperty]
+ private bool showAllLayers = true;
+
+ public LayeredMaskEditorViewModel(
+ IServiceManager vmFactory,
+ IImageIndexService imageIndexService,
+ ILogger logger
+ )
+ {
+ this.vmFactory = vmFactory;
+ this.imageIndexService = imageIndexService;
+ this.logger = logger;
+ PaintCanvasViewModel = vmFactory.Get();
+
+ // Set up Move tool callback to update image layer offsets
+ PaintCanvasViewModel.OnMoveToolDrag = (newOffsetX, newOffsetY) =>
+ {
+ if (SelectedLayer is { LayerType: MaskLayerType.Image })
+ {
+ SelectedLayer.ImageOffsetX = newOffsetX;
+ SelectedLayer.ImageOffsetY = newOffsetY;
+ SyncSelectedLayerToCanvas();
+ }
+ };
+
+ // Provide current offset when starting a move
+ PaintCanvasViewModel.GetCurrentMoveOffset = () =>
+ {
+ if (SelectedLayer is { LayerType: MaskLayerType.Image })
+ {
+ return (SelectedLayer.ImageOffsetX, SelectedLayer.ImageOffsetY);
+ }
+ return (0, 0);
+ };
+
+ // Subscribe to recent images
+ imageIndexService
+ .InferenceImages.ItemsSource.Connect()
+ .DeferUntilLoaded()
+ .SortBy(file => file.LastModifiedAt, SortDirection.Descending)
+ .Top(50) // Limit to 50 most recent
+ .Bind(LocalImages)
+ .Subscribe();
+
+ // Initialize with one layer
+ AddLayer();
+ }
+
+ ///