From 4fd1b13934e4b091ee5279ad598cddea04d6037f Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Aug 2026 21:50:39 -0700 Subject: [PATCH 01/27] Merge pull request #1330 from ionite34/fix/trainer-install-updates (cherry picked from commit bc144b3da5b66c8d80115c1f8bb34cdabd62690f) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 147 ++++++++++++++++++ .../Models/Packages/AiToolkit.cs | 27 ++-- .../Models/Packages/BaseGitPackage.cs | 70 +++++++++ .../Models/Packages/ComfyUI.cs | 51 +----- .../Models/Packages/OneTrainer.cs | 16 +- .../Rocm/OneTrainerWindowsRocmProfile.cs | 23 +-- 6 files changed, 255 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a6e7ff4..99c753923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,153 @@ 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). +<<<<<<< HEAD +======= +## v2.17.0-dev.3 +### Added +#### New Feature: 🎨 Qwen Image & Krea 2 in Inference +- New workflow profiles with auto-detection, automatic text encoder + VAE pairing, and one-click recommended sampler settings: + - **Qwen Image** (2512) — the top-ranked open text-to-image model, with best-in-class text rendering + - **Krea 2 Turbo** (8-step) and **Krea 2 Raw** — Krea's open-weights model, free for personal use under the Krea AI Community License + - Both need an up-to-date ComfyUI — update your package if the workflow is rejected +- Qwen-tagged CivitAI checkpoint files now route to the DiffusionModels folder like Wan/Flux/Krea already did +#### New Feature: ⬇️ One-Click Text Encoder & VAE Downloads +- When your selected model needs a text encoder or VAE you don't have, the Model card says exactly what's missing and offers the official downloads in one click — works for Flux, Flux.2 (4B/9B-aware), Z-Image, Anima, HiDream, Qwen Image, and Krea 2 +- Your model choice is never touched — bring any CivitAI fine-tune; only the shared official components are offered, download progress shows right in the card, and the finished files select themselves when done +- The text-encoder dropdowns also list the official encoders as cloud-download entries, with installed files now sorted above downloadable ones +#### New Feature: 🧩 Persistent Inference Layouts +- Rearranged panes in Inference now stick — one of our most-asked-for features, on Discord, GitHub ([#1340](https://github.com/LykosAI/StabilityMatrix/issues/1340)), and our feature tracker ([1](https://lykos.ai/feature/8f55f8d5-c25a-437f-a2ba-4093e3984754), [2](https://lykos.ai/feature/c407cc98-e355-4e8d-98a5-23be854ed6c0)) alike: + - Move and resize panes in any Inference tab and the arrangement is remembered for that tab type, including across restarts — new tabs of the same type open with your layout + - **Saving a project** stores the layout in the `.smproj` file, so every project can keep its own arrangement and it comes back when you reopen it + - **Restore Default Layout** now resets the current tab in place (no more page flicker) and returns that tab type to the standard arrangement + - Existing project files are unaffected and keep opening exactly as before +- Added **uv cache** management under **Settings → Package Environment** ([feature request](https://lykos.ai/feature/4dc8ab9d-e35f-436b-9a3f-d3abc2db1800)) — check the cache's size and clear it (after a quick explanation of what that means), with progress in the **Activity** panel and a report of the space freed. Optionally set a size limit to get a startup warning when the cache grows past it — off by default so launch stays lean — plus an automatic cleanup toggle +- Downloads in the **Activity** popout now show downloaded and total size (e.g. 1.2 GB / 6.5 GB) alongside the percentage and speed ([feature request](https://lykos.ai/feature/f571997c-3607-48db-a62f-b315fc733280)) +- Model downloads now skip files you already have ([feature request](https://lykos.ai/feature/3dc89d55-4859-4484-bde9-5951ad87a48a)) — when a download's hash matches an installed model, a notification points at the existing file instead of downloading a duplicate +- Added a **Quick-Hide Hotkey** for shared computers ([feature request](https://lykos.ai/feature/3dbfa0e1-650a-426a-906d-b81d6f414104)) — press a shortcut to instantly minimize Stability Matrix, or exit with no confirmation prompts. Off by default; enable it in **Settings → System** and bind any combination with the game-style key picker (default Ctrl+Shift+H) +### Changed +- Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) — with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs +- Updated the bundled **uv** package manager to 0.12.5, adding newer Python builds to the Python picker — including the 3.10 security releases requested in [#1709](https://github.com/LykosAI/StabilityMatrix/issues/1709) +### Fixed +- Fixed Qwen Image Edit's model check accepting an incompatible Krea 2 text encoder — the correct 7B encoder download is now offered instead +- Fixed clicking a cloud-download entry in model dropdowns sometimes doing nothing until you clicked a different item first +- Fixed jumpy, hard-to-reach scrolling near the cloud-download entries in model dropdowns +- Fixed switching model families after an app restart keeping the previous family's text encoders and slot count (e.g. Flux.2 Klein holding onto clip_l) — restored auto-picked components are now recognized as replaceable again; your own manual encoder picks stay protected +- Fixed companion downloads sometimes not filling their slot when the batch finished (e.g. the text encoder staying empty while the VAE filled) — each component now fills in as soon as its download completes, and the final pass waits for the model list to catch up +- Fixed the Model Browser failing with "CivitAI can't be reached right now (OK: OK)" — CivitAI recently started returning `null` for some model statistics (download counts, ratings), which broke loading the whole page of results. Missing stats are now read as 0 +- Fixed [#1695](https://github.com/LykosAI/StabilityMatrix/issues/1695) - models whose CivitAI page has since been deleted showing an error dialog on every click, with no way to break the link: + - The model details page now shows locally cached info instead of a dead page when the CivitAI page is gone + - **Next/Previous** on the details page skip over deleted models instead of getting stuck on an error + - New right-click **Disconnect from Source** action on Checkpoint Manager cards severs the link to the deleted page while keeping the local metadata (name, description, thumbnail, trigger words) — after disconnecting, clicking the card selects it like any other local model +- Fixed [#1708](https://github.com/LykosAI/StabilityMatrix/issues/1708) - OneTrainer on **Windows ROCm** crashing at the start of training with bitsandbytes 8-bit optimizers — the incompatible bitsandbytes build is no longer installed, and a launch notice explains why until OneTrainer supports the new version - thanks to @NeuralFault for the diagnosis! +- Fixed [#1710](https://github.com/LykosAI/StabilityMatrix/issues/1710) - CivitAI downloads sometimes fetching a different file from the same model version (then failing hash verification) when several files share identical metadata — downloads now pin the exact file +- Fixed [#1703](https://github.com/LykosAI/StabilityMatrix/issues/1703) - model cover/preview images not displaying when your **File Name Pattern** contains a `#` +- Fixed [#1705](https://github.com/LykosAI/StabilityMatrix/issues/1705) - the **Model Browser** showing results in a scrambled order after refreshing with the **Search** button +- Fixed [#1715](https://github.com/LykosAI/StabilityMatrix/issues/1715) - the app crashing on startup on Linux when the models folder contains folders or files differing only in letter case (e.g. `unet` and `Unet`, also [#1149](https://github.com/LykosAI/StabilityMatrix/issues/1149)/[#1357](https://github.com/LykosAI/StabilityMatrix/issues/1357)) — models in differently-cased folders now show up in the Checkpoint Manager instead of being silently skipped, and a model index error can no longer take down the app + +## v2.17.0-dev.2 +### Added +#### New Feature: 📋 Inference Prompt Queue +- Queue up multiple generations from any Inference tab and run them one after another — a long-requested feature ([#1622](https://github.com/LykosAI/StabilityMatrix/issues/1622)): + - **Add to Queue** sits next to Generate (and in the tab's overflow menu) in every generation tab — Text-to-Image, Image-to-Image, Flux, Upscale, and the video tabs. The Generate button itself is unchanged; queueing is a separate action + - Queue items store the full project state, not a baked workflow — **Open in tab** re-opens any item as a real Inference tab for tweaking and re-queueing + - Cards show a prompt snippet, compact parameters, a status badge, live progress and preview while running, and the finished thumbnail when done (click it to open the full image viewer) + - Manage the queue freely: reorder, remove, cancel the running item, **re-queue** finished/failed/cancelled items (or re-queue all finished), and clear finished or pending items + - **Start** auto-connects to ComfyUI if it's already running, or shows the usual launch prompt if it isn't; **Pause** finishes the current item before stopping + - The queue is saved to your library folder and survives restarts — interrupted items come back as Pending, and finished items keep their thumbnails +#### New Feature: 📖 In-App Documentation +- Read the Stability Matrix guides without leaving the app: + - Press **F1** from anywhere, or open it from **Settings → About → Documentation** + - New **?** buttons on the Package Manager, install details, the running package console, Inference, and the Environment Variables and App Folders settings open the page for what you're looking at + - Browse every section from a nav tree, follow links between pages, and zoom the text in or out — your zoom level is remembered + - Pages are read live from [docs.lykos.ai](https://docs.lykos.ai/stability-matrix/), so new and updated writing shows up without an app update, and a copy ships inside the app so it still works offline +- Added a **What's New** viewer — browse release notes for any version right in the app from **Settings → About**, with a one-time heads-up after each update (can be turned off in update settings) +- 📚 **New documentation site** at [docs.lykos.ai](https://docs.lykos.ai/stability-matrix/) — getting started and installation guides, package manager and Inference walkthroughs, environment variable and advanced configuration references, a terminology glossary, and troubleshooting for common issues. Written by @NeuralFault! +- Added **OneTrainer** to the native **Windows ROCm (AMD GPU)** helper — new OneTrainer installs on supported AMD hardware get the ROCm PyTorch build, ROCm-aware bitsandbytes and triton dependencies, and the right launch environment applied automatically. New OneTrainer installs also default to Python 3.12 on all platforms - thanks to @NeuralFault! +- Added Inference support for **IPAdapter** — guide a generation with a reference image alongside your prompt, for style, composition, or subject consistency without training a LoRA. Add it from the sampler's **Addons** section in any generation tab, right alongside ControlNet: + - Drop in a reference image, then pick an **IPAdapter model** and its matching **CLIP Vision** encoder — both dropdowns can download the files for you if you don't have them yet + - **Weight Type** chooses how the reference gets applied, from plain `linear` blending through the `style transfer` and `composition` modes that separate a reference's look from its layout + - **Control Weight** and **Control Steps** set how strongly the reference applies and over which portion of the generation, matching the controls on the ControlNet card + - Applies to every model loaded in the workflow, so base and refiner are both conditioned on the reference +### Changed +- CivitAI downloads now pick their destination folder from the file's declared type (**Diffusion Model**/**UNet** → DiffusionModels, **Text Encoder** → TextEncoders, **CLIP Vision** → ClipVision, **ControlNet** → ControlNet, **Upscaler** → upscalers) instead of guessing from the model's name. Name-based guessing remains only for files typed plain "Model", and now recognizes **Krea 2** checkpoints as UNet-only +- Updated the **Windows ROCm helper**'s bundled bitsandbytes wheel to a build compatible with ROCm 7.13–7.15, so it keeps working as AMD's ROCm Technical Preview builds update - thanks to @NeuralFault! +### Fixed +- Fixed a class of random crashes in the Inference **mask editor** and **image annotation editor** — undo/redo, layer operations, exporting, or closing the editor could free graphics resources that were still being drawn with, occasionally crashing mid-stroke or while saving. Canvas rendering has been restructured so this can't happen +- Fixed **pen pressure** re-widening the whole stroke instead of following the pen while drawing, and mouse-drawn strokes coming back ~25% thicker after saving and reopening a project. Existing project files load exactly as before +- Fixed the mask editor and image annotation editor leaking graphics memory — the paint canvas wasn't released on close, and paint-bucket fills were never freed +- Fixed fast brush strokes occasionally failing with a "collection was modified" error while the stroke was still being drawn +- Fixed opening older projects whose masks contained stroke points outside the canvas failing with an overflow error +- Fixed a potential crash when the paint canvas rendered before its size was set +- Fixed CivitAI models showing an empty Files section with no download links when their files use CivitAI's newer type labels (e.g. **Krea 2 Turbo** and **Z-Image**, typed **Diffusion Model** or **Text Encoder**). All current CivitAI file types are now recognized across the browser, details page, version dialog, bulk download, and installed/update detection +- Fixed CivitAI "Download with Stability Matrix" links saving UNet-only checkpoints (Flux, Wan Video, Hunyuan, Krea 2) into the **StableDiffusion** folder — external links now use the same destination logic as the in-app browser +- Fixed [#1668](https://github.com/LykosAI/StabilityMatrix/issues/1668) - the **Output Browser** crashing on open when an output folder contained a broken junction or symlink (a folder whose target no longer exists); those entries are now skipped instead of taking the page down +- Fixed [#1681](https://github.com/LykosAI/StabilityMatrix/issues/1681) - Inference generation with **FaceDetailer** failing instantly with "An item with the same key has already been added" when two installed custom node folders share the same git remote +- Fixed [#1679](https://github.com/LykosAI/StabilityMatrix/issues/1679) - model details pages never showing the **Installed** label, delete button, or version checkmark for files whose type displays as "Unknown". Installed detection now goes by file hash, so it works no matter what type CivitAI reports +- Fixed [#1667](https://github.com/LykosAI/StabilityMatrix/issues/1667) - importing an existing package folder silently pre-selecting **Forge** as the package type, misclassifying re-imported packages. The type is now auto-detected from the folder's git remote +- Fixed [#1669](https://github.com/LykosAI/StabilityMatrix/issues/1669) - **Stable Diffusion WebUI reForge** installing the CUDA build of PyTorch on Linux AMD systems even with ROCm selected +- Fixed [#1672](https://github.com/LykosAI/StabilityMatrix/issues/1672) - **Image Lab** failing with a generic "ComfyUI rejected the workflow" error when generating with a GGUF model while the **ComfyUI-GGUF** extension isn't installed — it now offers the same one-click **install and restart** prompt as Inference +- Fixed GGUF-quantized **text encoders** failing with a ComfyUI error when selected — they now load through the ComfyUI-GGUF CLIP loaders in all Inference workflows and Image Lab (mixed GGUF + safetensors selections work too), with the install prompt appearing if the extension is missing +- Fixed **Chroma** and other single-encoder models being impossible to configure in Inference UNet workflows — the encoder **Type** dropdown was missing `chroma` and other single-encoder types, and the closest option (`flux`) demanded a second encoder these models don't use. The missing types are now listed, and single-encoder types get a single encoder slot +- Fixed GGUF text encoders in the shared **TextEncoders** folder not appearing in the Inference Text Encoder dropdowns while connected to ComfyUI +- The **HuggingFace browser** now suggests the TextEncoders folder for recognizable text encoder files (`byt5`/`mt5`/`llama`/`gemma`/`qwen2` prefixes, `text-encoder`/`enconly` names) instead of defaulting every `.gguf` to DiffusionModels +- Fixed [#1682](https://github.com/LykosAI/StabilityMatrix/issues/1682) - the Linux **AppImage** failing to start on modern distros (Ubuntu 24.04+, Fedora 40+) that no longer ship `libfuse2`. The AppImage now uses a FUSE3-based runtime, and falls back to extract-and-run when FUSE isn't available at all - thanks to @NeuralFault! +- Fixed **OneTrainer** failing to launch with current upstream versions after its UI script was renamed (`train_ui.py` → `train_ui_ctk.py`) - thanks to @NeuralFault! +### Performance +- Dragging an image layer with the **Move** tool in the layered mask editor now re-renders only the dragged layer instead of re-compositing every layer on each pointer move, so dragging stays smooth in multi-layer projects +- Faster color-mask extraction for regional prompting — a 1024×1024 canvas with six regions previously did over six million dictionary lookups per extraction +- Fixed a small native memory leak while drawing with the mouse (a path object per frame was never freed), and removed a redundant full-canvas scan after every paint-bucket fill +### Security +- Bundled **ADetailer** model downloads now point at a fixed Hugging Face revision instead of the repository's moving `main` branch, so an upstream re-upload can't change what you get - thanks to @ungrav! +### Supporters +#### 🌟 Visionaries +The prompt queue has been one of our most requested features ever, and builds like this only happen because our Visionaries give us the freedom to take them on. Thank you **Waterclouds**, **MrMxyzptlk12836**, **Psilocyfer18731**, **bluepopsicle**, **Ibixat**, **Droolguy**, **KalAbaddon**, **LG**, **snotty**, **whudunit**, **cusalapapen1481**, **moon_milky2843**, **SkynetFuture**, and **sn3232323233350** for standing behind us build after build. And to our three newest Visionaries, **tarekk071223**, **CC**, and **SnooSnooEternal**: welcome aboard, it means the world to have you with us. 💛 + +## v2.17.0-dev.1 +### Added +#### New Feature: 🤗 Live HuggingFace Model Browser +- Reworked the HuggingFace tab into a full browser instead of a fixed list of links — the curated picks are still there as the default view: + - Search HuggingFace for models right from the tab, sorted by downloads, likes, or most recently updated + - Paste a repository link to browse all of its files directly + - Browse files as a flat list or a folder tree, with a quick name filter and a **Hide installed** toggle + - Choose where each file goes (auto-detected and editable), or send a whole multi-file model such as a diffusers repo into one folder with its structure intact + - Select multiple files at once and see the total download size before you start, with a warning if there isn't enough free space + - Gated or private repositories work once you add a HuggingFace token in **Settings → Accounts** +- Added Inference support for the **Wan 2.2 14B** mixture-of-experts video models. Enable **Dual expert (high / low noise)** from the Wan model card's options menu (⚙️) to load a second low-noise expert alongside the high-noise model; generation then runs the two-pass high-noise → low-noise sampling the 14B architecture expects, switching at a configurable **Boundary** (the fraction of steps handled by the high-noise expert, default 0.5). Works in both Wan Text to Video and Image to Video, and leaving the low-noise slot empty keeps the existing single-model Wan behavior unchanged + - When you pick a model that looks like one half of a 14B expert pair (e.g. `wan2.2_t2v_high_noise_14B`), the card offers a one-click prompt to enable the second expert and auto-selects its matching low-noise counterpart + - The high-noise and low-noise model pickers sit together at the top as a labeled pair, with Precision, VAE, Text Encoder and Shift tucked into an **Advanced** section to keep the card approachable + - Added a separate **Low-Noise LoRAs** list so per-expert speed LoRAs (such as Wan 2.2-Lightning's high/low-noise pair) land on the correct model — the **High-Noise LoRAs** list applies to the high-noise/primary model, and the new Low-Noise LoRAs list applies to the low-noise expert + - Added hover tooltips to the Wan model card fields (Precision, VAE, Text Encoder, Shift, Low-Noise, Boundary) explaining what each one does +- Added a **gallery picker** for the Inference "+" new-tab button, grouping the project types into **Image / Video / Legacy** sections with icons and descriptions instead of a flat dropdown. The redundant standalone Flux text-to-image and SVD image-to-video types now live under **Legacy** so existing projects still open, without cluttering the list for new users + - Prefer the old menu? A **Compact New Tab Menu** toggle under Settings → Inference (and a quick link at the bottom of the picker dialog) switches the "+" button back to the fast dropdown +### Changed +- **Windows builds are now code-signed.** The portable executable is signed with a verified certificate, so Windows SmartScreen no longer flags Stability Matrix as from an unknown publisher. You may still see a SmartScreen prompt on the first releases while the certificate builds reputation, but those warnings will taper off as more people run signed builds +- **Generate now auto-resumes after launching ComfyUI.** If you press Generate in Inference while ComfyUI isn't connected and choose to launch it from the connection prompt, the queued generation now waits for startup and runs on its own once connected - no need to press Generate a second time +### Fixed +- Fixed the Inference **VAE** dropdown listing models in a seemingly random order, and the **Text Encoder** / **CLIP Vision** dropdowns occasionally reordering as the list finished loading; all three now sort alphabetically, with **Default** pinned to the top of the VAE list +- Model dropdowns no longer reserve space for a thumbnail when the selected model has no preview image, so names are no longer squished into a narrow strip +- Fixed [#1666](https://github.com/LykosAI/StabilityMatrix/issues/1666) - AppImage builds creating a broken `.desktop` entry (`NoDisplay=true`, missing icon) that never showed up in the application launcher and reverted any manual edits on the next launch. AppImage runs now write a correct `.desktop` entry with the extracted app icon so Stability Matrix appears in your launcher/menu, and report a matching `WM_CLASS` so the running window shows the Stability Matrix icon in the dock/taskbar instead of a generic one. Only applies to AppImage runs; deb/rpm/flatpak installs keep their package-managed entries +- Fixed `stabilitymatrix://` deep links (e.g. CivitAI "Download with Stability Matrix" buttons) being ignored on Linux AppImage builds — the URI is now forwarded to the running instance and the download starts, instead of just opening another window +### Performance +- The **Checkpoints** and **Outputs** galleries now load thumbnails at display size instead of full resolution, using far less memory and scrolling much more smoothly with large libraries +- The **CivitAI** and **OpenModelDB** browsers now keep card images in memory, so scrolling back over models you've already seen no longer re-loads them from disk +- Lightened the CivitAI model cards so they render faster while scrolling +### Supporters +#### 🌟 Visionaries +This build leans hard into what's next: a live HuggingFace browser and Wan 2.2 video generation. None of that exploration happens without our Visionaries giving us the room to chase it. So a huge thank you to **Waterclouds**, **MrMxyzptlk12836**, **Psilocyfer18731**, **bluepopsicle**, **Ibixat**, **Droolguy**, **KalAbaddon**, **LG**, **snotty**, **whudunit**, **cusalapapen1481**, and **moon_milky2843**. You make the experiments possible. And a big hello to **SkynetFuture** and **sn3232323233350**, who join the Visionary crew this time around. We're genuinely glad you're here. 💛 + +## v2.16.3 +### Changed +- Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) — with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs +- Updated the bundled **uv** package manager to 0.12.5, adding newer Python builds to the Python picker — including the 3.10 security releases requested in [#1709](https://github.com/LykosAI/StabilityMatrix/issues/1709) +### Fixed +- Fixed [#1708](https://github.com/LykosAI/StabilityMatrix/issues/1708) - OneTrainer on **Windows ROCm** crashing at the start of training with bitsandbytes 8-bit optimizers — the incompatible bitsandbytes build is no longer installed, and a launch notice explains why until OneTrainer supports the new version - thanks to @NeuralFault for the diagnosis! +- Fixed [#1710](https://github.com/LykosAI/StabilityMatrix/issues/1710) - CivitAI downloads sometimes fetching a different file from the same model version (then failing hash verification) when several files share identical metadata — downloads now pin the exact file +- Fixed [#1703](https://github.com/LykosAI/StabilityMatrix/issues/1703) - model cover/preview images not displaying when your **File Name Pattern** contains a `#` +- Fixed [#1705](https://github.com/LykosAI/StabilityMatrix/issues/1705) - the **Model Browser** showing results in a scrambled order after refreshing with the **Search** button +- Fixed [#1715](https://github.com/LykosAI/StabilityMatrix/issues/1715) - the app crashing on startup on Linux when the models folder contains folders or files differing only in letter case (e.g. `unet` and `Unet`, also [#1149](https://github.com/LykosAI/StabilityMatrix/issues/1149)/[#1357](https://github.com/LykosAI/StabilityMatrix/issues/1357)) — models in differently-cased folders now show up in the Checkpoint Manager instead of being silently skipped, and a model index error can no longer take down the app + +>>>>>>> bc144b3d (Merge pull request #1330 from ionite34/fix/trainer-install-updates) ## v2.16.2 > Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.16.2](https://docs.lykos.ai/stability-matrix/release-notes/2.16.2) ### Added diff --git a/StabilityMatrix.Core/Models/Packages/AiToolkit.cs b/StabilityMatrix.Core/Models/Packages/AiToolkit.cs index d40bb083a..560c33a11 100644 --- a/StabilityMatrix.Core/Models/Packages/AiToolkit.cs +++ b/StabilityMatrix.Core/Models/Packages/AiToolkit.cs @@ -60,7 +60,7 @@ IPipWheelService pipWheelService public override PackageType PackageType => PackageType.SdTraining; public override bool OfferInOneClickInstaller => false; public override bool ShouldIgnoreReleases => true; - public override PyVersion RecommendedPythonVersion => Python.PyInstallationManager.Python_3_11_13; + public override PyVersion RecommendedPythonVersion => Python.PyInstallationManager.Python_3_12_10; public override IEnumerable Prerequisites => base.Prerequisites.Concat([PackagePrerequisite.Node]); @@ -88,12 +88,13 @@ public override async Task InstallPackage( var config = new PipInstallConfig { RequirementsFilePaths = ["requirements.txt"], - // Upstream (ostris/ai-toolkit README) installs torch 2.9.1 / cu128. - TorchVersion = "==2.9.1", - TorchvisionVersion = "==0.24.1", - TorchaudioVersion = "==2.9.1", - // cu128 by default; keep cu126 for legacy NVIDIA GPUs without cu128 support. - CudaIndex = isLegacyNvidia ? "cu126" : "cu128", + // Upstream (ostris/ai-toolkit README) installs torch 2.13.0 / cu130. + TorchVersion = "==2.13.0", + TorchvisionVersion = "==0.28.0", + TorchaudioVersion = "==2.11.0", + // cu130 by default (requires NVIDIA driver 580+); keep cu126 for legacy NVIDIA GPUs. + // torch 2.13.0 publishes wheels on both indexes. + CudaIndex = isLegacyNvidia ? "cu126" : "cu130", ExtraPipArgs = [Compat.IsWindows ? "triton-windows" : "triton"], // ai-toolkit doesn't pin numpy, so it floats to 2.x and breaks the scipy/diffusers // C-extensions (built for numpy 1.x): "numpy.dtype size changed... binary incompatibility". @@ -140,6 +141,11 @@ await SetupVenv(installLocation, pythonVersion: PyVersion.Parse(installedPackage .ConfigureAwait(false); VenvRunner.UpdateEnvironmentVariables(GetEnvVars); + if (await WarnIfNvidiaDriverBelowCu130MinimumAsync(VenvRunner, onConsoleOutput).ConfigureAwait(false)) + { + return; + } + var uiDirectory = new DirectoryPath(installLocation, "ui"); var envVars = GetEnvVars(VenvRunner.EnvironmentVariables); npmProcess = PrerequisiteHelper.RunNpmDetached( @@ -195,8 +201,11 @@ await npmProcess private ImmutableDictionary GetEnvVars(ImmutableDictionary env) { - // set SETUPTOOLS_USE_DISTUTILS=setuptools to avoid job errors - env = env.SetItem("SETUPTOOLS_USE_DISTUTILS", "setuptools"); + // Keep distutils importable for setuptools-based builds and training jobs. Must be + // "local" (setuptools' bundled copy): any other value falls back to stdlib distutils, + // which no longer exists on Python 3.12+ and breaks source builds (e.g. the pinned + // diffusers git commit in ai-toolkit's requirements). + env = env.SetItem("SETUPTOOLS_USE_DISTUTILS", "local"); var pathBuilder = new EnvPathBuilder(); diff --git a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs index 2d16f3c2c..67c877bb0 100644 --- a/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs +++ b/StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs @@ -6,6 +6,7 @@ using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; @@ -879,6 +880,75 @@ public virtual async Task SendInputAsync(string input) await process.StandardInput.WriteLineAsync(input).ConfigureAwait(false); } + /// + /// Emits a console and log warning when the venv's installed torch is a cu130 build but the + /// NVIDIA driver is older than the 580.x minimum required by CUDA 13.0. + /// Returns true when the incompatibility was detected; callers typically abort the launch. + /// + protected async Task WarnIfNvidiaDriverBelowCu130MinimumAsync( + IPyVenvRunner venvRunner, + Action? onConsoleOutput + ) + { + var isNvidia = SettingsManager.Settings.PreferredGpu?.IsNvidia ?? HardwareHelper.HasNvidiaGpu(); + var isLegacyNvidia = + SettingsManager.Settings.PreferredGpu?.IsLegacyNvidiaGpu() ?? HardwareHelper.HasLegacyNvidiaGpu(); + + if (!isNvidia || isLegacyNvidia) + { + return false; + } + + var driverVersion = HardwareHelper.GetNvidiaDriverVersion(); + if (driverVersion is null || driverVersion.Major >= 580) + { + return false; + } + + var torchInfo = await venvRunner.PipShow("torch").ConfigureAwait(false); + if (torchInfo is null) + { + return false; + } + + var version = torchInfo.Version; + var plusPos = version.IndexOf('+'); + var torchIndex = plusPos >= 0 ? version[(plusPos + 1)..] : string.Empty; + + if (!torchIndex.Equals("cu130", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var warningMessage = $""" + + ============================================================ + NVIDIA DRIVER WARNING + ============================================================ + + Your NVIDIA driver version ({driverVersion}) is older than + the minimum required version (580.x) for CUDA 13.0 (cu130). + + This may cause {DisplayName} to fail to start or experience issues. + + Recommended actions: + 1. Update your NVIDIA driver to version 580 or newer + 2. Or manually downgrade your torch version to use an + older torch index (e.g. cu128) + + ============================================================ + + """; + + Logger.Warn( + "NVIDIA driver version {DriverVersion} is below 580.x minimum for cu130 (torch index: {TorchIndex})", + driverVersion, + torchIndex + ); + onConsoleOutput?.Invoke(ProcessOutput.FromStdErrLine(warningMessage)); + return true; + } + protected PipInstallArgs GetTorchPipArgs( TorchIndex torchIndex, string torchVersion = "", diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index 0b4c530fa..a59792e44 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -604,56 +604,9 @@ await SetupVenv(installLocation, pythonVersion: PyVersion.Parse(installedPackage VenvRunner.UpdateEnvironmentVariables(env => env.Remove("UV_BUILD_CONSTRAINT")); // Check for old NVIDIA driver version with cu130 installations - var isNvidia = SettingsManager.Settings.PreferredGpu?.IsNvidia ?? HardwareHelper.HasNvidiaGpu(); - var isLegacyNvidia = - SettingsManager.Settings.PreferredGpu?.IsLegacyNvidiaGpu() ?? HardwareHelper.HasLegacyNvidiaGpu(); - - if (isNvidia && !isLegacyNvidia) + if (await WarnIfNvidiaDriverBelowCu130MinimumAsync(VenvRunner, onConsoleOutput).ConfigureAwait(false)) { - var driverVersion = HardwareHelper.GetNvidiaDriverVersion(); - if (driverVersion is not null && driverVersion.Major < 580) - { - // Check if torch is installed with cu130 index - var torchInfo = await VenvRunner.PipShow("torch").ConfigureAwait(false); - if (torchInfo is not null) - { - var version = torchInfo.Version; - var plusPos = version.IndexOf('+'); - var torchIndex = plusPos >= 0 ? version[(plusPos + 1)..] : string.Empty; - - // Only warn if using cu130 (which requires driver 580+) - if (torchIndex.Equals("cu130", StringComparison.OrdinalIgnoreCase)) - { - var warningMessage = $""" - - ============================================================ - NVIDIA DRIVER WARNING - ============================================================ - - Your NVIDIA driver version ({driverVersion}) is older than - the minimum required version (580.x) for CUDA 13.0 (cu130). - - This may cause ComfyUI to fail to start or experience issues. - - Recommended actions: - 1. Update your NVIDIA driver to version 580 or newer - 2. Or manually downgrade your torch version to use an - older torch index (e.g. cu128) - - ============================================================ - - """; - - Logger.Warn( - "NVIDIA driver version {DriverVersion} is below 580.x minimum for cu130 (torch index: {TorchIndex})", - driverVersion, - torchIndex - ); - onConsoleOutput?.Invoke(ProcessOutput.FromStdErrLine(warningMessage)); - return; - } - } - } + return; } var handledFirstConsoleOutput = false; diff --git a/StabilityMatrix.Core/Models/Packages/OneTrainer.cs b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs index e51771c4f..68afe2a24 100644 --- a/StabilityMatrix.Core/Models/Packages/OneTrainer.cs +++ b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs @@ -84,7 +84,6 @@ public override async Task InstallPackage( .ConfigureAwait(false); var torchVersion = options.PythonOptions.TorchIndex ?? GetRecommendedTorchVersion(); - var pyVersion = options.PythonOptions.PythonVersion ?? RecommendedPythonVersion; // Windows ROCm path var isWindowsRocm = @@ -120,7 +119,7 @@ await rocmPackageHelper .InstallWindowsNativeTorchAsync( venvRunner, installedPackage, - OneTrainerWindowsRocmProfile.CreateInstallProfile(pyVersion), + OneTrainerWindowsRocmProfile.CreateInstallProfile(), progress, onConsoleOutput, cancellationToken @@ -195,7 +194,18 @@ await SetupVenv(installLocation, pythonVersion: PyVersion.Parse(installedPackage private IReadOnlyList GetLaunchNoticeLines(InstalledPackage installedPackage) { var selectedTorchIndex = installedPackage.PreferredTorchIndex ?? GetRecommendedTorchVersion(); - return rocmPackageHelper.GetWindowsLaunchNoticeLines(selectedTorchIndex); + + if (!rocmPackageHelper.ShouldApplyWindowsLaunchEnvironment(selectedTorchIndex)) + { + return []; + } + + return + [ + .. rocmPackageHelper.GetWindowsLaunchNoticeLines(selectedTorchIndex), + "Note: bitsandbytes 8-bit optimizers are temporarily unavailable on Windows ROCm " + + "until OneTrainer supports bitsandbytes 0.50.", + ]; } public override List LaunchOptions => [LaunchOptionDefinition.Extras]; diff --git a/StabilityMatrix.Core/Models/Rocm/OneTrainerWindowsRocmProfile.cs b/StabilityMatrix.Core/Models/Rocm/OneTrainerWindowsRocmProfile.cs index ece86e74a..a8b911bcb 100644 --- a/StabilityMatrix.Core/Models/Rocm/OneTrainerWindowsRocmProfile.cs +++ b/StabilityMatrix.Core/Models/Rocm/OneTrainerWindowsRocmProfile.cs @@ -1,11 +1,9 @@ using StabilityMatrix.Core.Models.Packages; -using StabilityMatrix.Core.Python; namespace StabilityMatrix.Core.Models.Rocm; /// /// Shared Windows ROCm profile for OneTrainer. -/// Python 3.12 only - required by compatible bitsandbytes wheel. /// public class OneTrainerWindowsRocmProfile : RocmPackageProfile { @@ -14,23 +12,12 @@ public class OneTrainerWindowsRocmProfile : RocmPackageProfile // Restores flop counter functionality requiring triton module private const string TritonWindowsPackage = "triton-windows"; - // Replace upstream bitsandbytes with ROCm-aware bitsandbytes for ROCm Technical Preview on Windows - private const string BitsAndBytesWheelUrl = - "https://github.com/0xDELUXA/bitsandbytes_win_rocm/releases/download/0.50.0.dev0-py3.12-rocm7.15-win_amd64_all/bitsandbytes-0.50.0.dev0-cp312-cp312-win_amd64.whl"; - - public static RocmPackageProfile CreateInstallProfile(PyVersion pyVersion) + // bitsandbytes is deliberately not installed here: OneTrainer pins bnb 0.49.x, but the only + // Windows ROCm wheels for current ROCm (>= 7.13) are 0.50-based, which removed optimizer + // arguments OneTrainer still passes (block_wise / percentile_clipping), so 8-bit optimizers + // crash at startup. Re-enable once upstream OneTrainer supports bnb 0.50. + public static RocmPackageProfile CreateInstallProfile() { - if (pyVersion.Major == 3 && pyVersion.Minor == 12) - { - return new RocmPackageProfile - { - InstallConfig = new PipInstallConfig - { - PostTorchInstallPipArgs = [TritonWindowsPackage, BitsAndBytesWheelUrl], - }, - }; - } - return new RocmPackageProfile { InstallConfig = new PipInstallConfig { PostTorchInstallPipArgs = [TritonWindowsPackage] }, From 2381396a62ba5e4889646438bad979a396b3f5cf Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Aug 2026 22:01:08 -0700 Subject: [PATCH 02/27] fix chagenlog merge --- CHANGELOG.md | 136 --------------------------------------------------- 1 file changed, 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c753923..3c6834521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,141 +5,6 @@ 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). -<<<<<<< HEAD -======= -## v2.17.0-dev.3 -### Added -#### New Feature: 🎨 Qwen Image & Krea 2 in Inference -- New workflow profiles with auto-detection, automatic text encoder + VAE pairing, and one-click recommended sampler settings: - - **Qwen Image** (2512) — the top-ranked open text-to-image model, with best-in-class text rendering - - **Krea 2 Turbo** (8-step) and **Krea 2 Raw** — Krea's open-weights model, free for personal use under the Krea AI Community License - - Both need an up-to-date ComfyUI — update your package if the workflow is rejected -- Qwen-tagged CivitAI checkpoint files now route to the DiffusionModels folder like Wan/Flux/Krea already did -#### New Feature: ⬇️ One-Click Text Encoder & VAE Downloads -- When your selected model needs a text encoder or VAE you don't have, the Model card says exactly what's missing and offers the official downloads in one click — works for Flux, Flux.2 (4B/9B-aware), Z-Image, Anima, HiDream, Qwen Image, and Krea 2 -- Your model choice is never touched — bring any CivitAI fine-tune; only the shared official components are offered, download progress shows right in the card, and the finished files select themselves when done -- The text-encoder dropdowns also list the official encoders as cloud-download entries, with installed files now sorted above downloadable ones -#### New Feature: 🧩 Persistent Inference Layouts -- Rearranged panes in Inference now stick — one of our most-asked-for features, on Discord, GitHub ([#1340](https://github.com/LykosAI/StabilityMatrix/issues/1340)), and our feature tracker ([1](https://lykos.ai/feature/8f55f8d5-c25a-437f-a2ba-4093e3984754), [2](https://lykos.ai/feature/c407cc98-e355-4e8d-98a5-23be854ed6c0)) alike: - - Move and resize panes in any Inference tab and the arrangement is remembered for that tab type, including across restarts — new tabs of the same type open with your layout - - **Saving a project** stores the layout in the `.smproj` file, so every project can keep its own arrangement and it comes back when you reopen it - - **Restore Default Layout** now resets the current tab in place (no more page flicker) and returns that tab type to the standard arrangement - - Existing project files are unaffected and keep opening exactly as before -- Added **uv cache** management under **Settings → Package Environment** ([feature request](https://lykos.ai/feature/4dc8ab9d-e35f-436b-9a3f-d3abc2db1800)) — check the cache's size and clear it (after a quick explanation of what that means), with progress in the **Activity** panel and a report of the space freed. Optionally set a size limit to get a startup warning when the cache grows past it — off by default so launch stays lean — plus an automatic cleanup toggle -- Downloads in the **Activity** popout now show downloaded and total size (e.g. 1.2 GB / 6.5 GB) alongside the percentage and speed ([feature request](https://lykos.ai/feature/f571997c-3607-48db-a62f-b315fc733280)) -- Model downloads now skip files you already have ([feature request](https://lykos.ai/feature/3dc89d55-4859-4484-bde9-5951ad87a48a)) — when a download's hash matches an installed model, a notification points at the existing file instead of downloading a duplicate -- Added a **Quick-Hide Hotkey** for shared computers ([feature request](https://lykos.ai/feature/3dbfa0e1-650a-426a-906d-b81d6f414104)) — press a shortcut to instantly minimize Stability Matrix, or exit with no confirmation prompts. Off by default; enable it in **Settings → System** and bind any combination with the game-style key picker (default Ctrl+Shift+H) -### Changed -- Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) — with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs -- Updated the bundled **uv** package manager to 0.12.5, adding newer Python builds to the Python picker — including the 3.10 security releases requested in [#1709](https://github.com/LykosAI/StabilityMatrix/issues/1709) -### Fixed -- Fixed Qwen Image Edit's model check accepting an incompatible Krea 2 text encoder — the correct 7B encoder download is now offered instead -- Fixed clicking a cloud-download entry in model dropdowns sometimes doing nothing until you clicked a different item first -- Fixed jumpy, hard-to-reach scrolling near the cloud-download entries in model dropdowns -- Fixed switching model families after an app restart keeping the previous family's text encoders and slot count (e.g. Flux.2 Klein holding onto clip_l) — restored auto-picked components are now recognized as replaceable again; your own manual encoder picks stay protected -- Fixed companion downloads sometimes not filling their slot when the batch finished (e.g. the text encoder staying empty while the VAE filled) — each component now fills in as soon as its download completes, and the final pass waits for the model list to catch up -- Fixed the Model Browser failing with "CivitAI can't be reached right now (OK: OK)" — CivitAI recently started returning `null` for some model statistics (download counts, ratings), which broke loading the whole page of results. Missing stats are now read as 0 -- Fixed [#1695](https://github.com/LykosAI/StabilityMatrix/issues/1695) - models whose CivitAI page has since been deleted showing an error dialog on every click, with no way to break the link: - - The model details page now shows locally cached info instead of a dead page when the CivitAI page is gone - - **Next/Previous** on the details page skip over deleted models instead of getting stuck on an error - - New right-click **Disconnect from Source** action on Checkpoint Manager cards severs the link to the deleted page while keeping the local metadata (name, description, thumbnail, trigger words) — after disconnecting, clicking the card selects it like any other local model -- Fixed [#1708](https://github.com/LykosAI/StabilityMatrix/issues/1708) - OneTrainer on **Windows ROCm** crashing at the start of training with bitsandbytes 8-bit optimizers — the incompatible bitsandbytes build is no longer installed, and a launch notice explains why until OneTrainer supports the new version - thanks to @NeuralFault for the diagnosis! -- Fixed [#1710](https://github.com/LykosAI/StabilityMatrix/issues/1710) - CivitAI downloads sometimes fetching a different file from the same model version (then failing hash verification) when several files share identical metadata — downloads now pin the exact file -- Fixed [#1703](https://github.com/LykosAI/StabilityMatrix/issues/1703) - model cover/preview images not displaying when your **File Name Pattern** contains a `#` -- Fixed [#1705](https://github.com/LykosAI/StabilityMatrix/issues/1705) - the **Model Browser** showing results in a scrambled order after refreshing with the **Search** button -- Fixed [#1715](https://github.com/LykosAI/StabilityMatrix/issues/1715) - the app crashing on startup on Linux when the models folder contains folders or files differing only in letter case (e.g. `unet` and `Unet`, also [#1149](https://github.com/LykosAI/StabilityMatrix/issues/1149)/[#1357](https://github.com/LykosAI/StabilityMatrix/issues/1357)) — models in differently-cased folders now show up in the Checkpoint Manager instead of being silently skipped, and a model index error can no longer take down the app - -## v2.17.0-dev.2 -### Added -#### New Feature: 📋 Inference Prompt Queue -- Queue up multiple generations from any Inference tab and run them one after another — a long-requested feature ([#1622](https://github.com/LykosAI/StabilityMatrix/issues/1622)): - - **Add to Queue** sits next to Generate (and in the tab's overflow menu) in every generation tab — Text-to-Image, Image-to-Image, Flux, Upscale, and the video tabs. The Generate button itself is unchanged; queueing is a separate action - - Queue items store the full project state, not a baked workflow — **Open in tab** re-opens any item as a real Inference tab for tweaking and re-queueing - - Cards show a prompt snippet, compact parameters, a status badge, live progress and preview while running, and the finished thumbnail when done (click it to open the full image viewer) - - Manage the queue freely: reorder, remove, cancel the running item, **re-queue** finished/failed/cancelled items (or re-queue all finished), and clear finished or pending items - - **Start** auto-connects to ComfyUI if it's already running, or shows the usual launch prompt if it isn't; **Pause** finishes the current item before stopping - - The queue is saved to your library folder and survives restarts — interrupted items come back as Pending, and finished items keep their thumbnails -#### New Feature: 📖 In-App Documentation -- Read the Stability Matrix guides without leaving the app: - - Press **F1** from anywhere, or open it from **Settings → About → Documentation** - - New **?** buttons on the Package Manager, install details, the running package console, Inference, and the Environment Variables and App Folders settings open the page for what you're looking at - - Browse every section from a nav tree, follow links between pages, and zoom the text in or out — your zoom level is remembered - - Pages are read live from [docs.lykos.ai](https://docs.lykos.ai/stability-matrix/), so new and updated writing shows up without an app update, and a copy ships inside the app so it still works offline -- Added a **What's New** viewer — browse release notes for any version right in the app from **Settings → About**, with a one-time heads-up after each update (can be turned off in update settings) -- 📚 **New documentation site** at [docs.lykos.ai](https://docs.lykos.ai/stability-matrix/) — getting started and installation guides, package manager and Inference walkthroughs, environment variable and advanced configuration references, a terminology glossary, and troubleshooting for common issues. Written by @NeuralFault! -- Added **OneTrainer** to the native **Windows ROCm (AMD GPU)** helper — new OneTrainer installs on supported AMD hardware get the ROCm PyTorch build, ROCm-aware bitsandbytes and triton dependencies, and the right launch environment applied automatically. New OneTrainer installs also default to Python 3.12 on all platforms - thanks to @NeuralFault! -- Added Inference support for **IPAdapter** — guide a generation with a reference image alongside your prompt, for style, composition, or subject consistency without training a LoRA. Add it from the sampler's **Addons** section in any generation tab, right alongside ControlNet: - - Drop in a reference image, then pick an **IPAdapter model** and its matching **CLIP Vision** encoder — both dropdowns can download the files for you if you don't have them yet - - **Weight Type** chooses how the reference gets applied, from plain `linear` blending through the `style transfer` and `composition` modes that separate a reference's look from its layout - - **Control Weight** and **Control Steps** set how strongly the reference applies and over which portion of the generation, matching the controls on the ControlNet card - - Applies to every model loaded in the workflow, so base and refiner are both conditioned on the reference -### Changed -- CivitAI downloads now pick their destination folder from the file's declared type (**Diffusion Model**/**UNet** → DiffusionModels, **Text Encoder** → TextEncoders, **CLIP Vision** → ClipVision, **ControlNet** → ControlNet, **Upscaler** → upscalers) instead of guessing from the model's name. Name-based guessing remains only for files typed plain "Model", and now recognizes **Krea 2** checkpoints as UNet-only -- Updated the **Windows ROCm helper**'s bundled bitsandbytes wheel to a build compatible with ROCm 7.13–7.15, so it keeps working as AMD's ROCm Technical Preview builds update - thanks to @NeuralFault! -### Fixed -- Fixed a class of random crashes in the Inference **mask editor** and **image annotation editor** — undo/redo, layer operations, exporting, or closing the editor could free graphics resources that were still being drawn with, occasionally crashing mid-stroke or while saving. Canvas rendering has been restructured so this can't happen -- Fixed **pen pressure** re-widening the whole stroke instead of following the pen while drawing, and mouse-drawn strokes coming back ~25% thicker after saving and reopening a project. Existing project files load exactly as before -- Fixed the mask editor and image annotation editor leaking graphics memory — the paint canvas wasn't released on close, and paint-bucket fills were never freed -- Fixed fast brush strokes occasionally failing with a "collection was modified" error while the stroke was still being drawn -- Fixed opening older projects whose masks contained stroke points outside the canvas failing with an overflow error -- Fixed a potential crash when the paint canvas rendered before its size was set -- Fixed CivitAI models showing an empty Files section with no download links when their files use CivitAI's newer type labels (e.g. **Krea 2 Turbo** and **Z-Image**, typed **Diffusion Model** or **Text Encoder**). All current CivitAI file types are now recognized across the browser, details page, version dialog, bulk download, and installed/update detection -- Fixed CivitAI "Download with Stability Matrix" links saving UNet-only checkpoints (Flux, Wan Video, Hunyuan, Krea 2) into the **StableDiffusion** folder — external links now use the same destination logic as the in-app browser -- Fixed [#1668](https://github.com/LykosAI/StabilityMatrix/issues/1668) - the **Output Browser** crashing on open when an output folder contained a broken junction or symlink (a folder whose target no longer exists); those entries are now skipped instead of taking the page down -- Fixed [#1681](https://github.com/LykosAI/StabilityMatrix/issues/1681) - Inference generation with **FaceDetailer** failing instantly with "An item with the same key has already been added" when two installed custom node folders share the same git remote -- Fixed [#1679](https://github.com/LykosAI/StabilityMatrix/issues/1679) - model details pages never showing the **Installed** label, delete button, or version checkmark for files whose type displays as "Unknown". Installed detection now goes by file hash, so it works no matter what type CivitAI reports -- Fixed [#1667](https://github.com/LykosAI/StabilityMatrix/issues/1667) - importing an existing package folder silently pre-selecting **Forge** as the package type, misclassifying re-imported packages. The type is now auto-detected from the folder's git remote -- Fixed [#1669](https://github.com/LykosAI/StabilityMatrix/issues/1669) - **Stable Diffusion WebUI reForge** installing the CUDA build of PyTorch on Linux AMD systems even with ROCm selected -- Fixed [#1672](https://github.com/LykosAI/StabilityMatrix/issues/1672) - **Image Lab** failing with a generic "ComfyUI rejected the workflow" error when generating with a GGUF model while the **ComfyUI-GGUF** extension isn't installed — it now offers the same one-click **install and restart** prompt as Inference -- Fixed GGUF-quantized **text encoders** failing with a ComfyUI error when selected — they now load through the ComfyUI-GGUF CLIP loaders in all Inference workflows and Image Lab (mixed GGUF + safetensors selections work too), with the install prompt appearing if the extension is missing -- Fixed **Chroma** and other single-encoder models being impossible to configure in Inference UNet workflows — the encoder **Type** dropdown was missing `chroma` and other single-encoder types, and the closest option (`flux`) demanded a second encoder these models don't use. The missing types are now listed, and single-encoder types get a single encoder slot -- Fixed GGUF text encoders in the shared **TextEncoders** folder not appearing in the Inference Text Encoder dropdowns while connected to ComfyUI -- The **HuggingFace browser** now suggests the TextEncoders folder for recognizable text encoder files (`byt5`/`mt5`/`llama`/`gemma`/`qwen2` prefixes, `text-encoder`/`enconly` names) instead of defaulting every `.gguf` to DiffusionModels -- Fixed [#1682](https://github.com/LykosAI/StabilityMatrix/issues/1682) - the Linux **AppImage** failing to start on modern distros (Ubuntu 24.04+, Fedora 40+) that no longer ship `libfuse2`. The AppImage now uses a FUSE3-based runtime, and falls back to extract-and-run when FUSE isn't available at all - thanks to @NeuralFault! -- Fixed **OneTrainer** failing to launch with current upstream versions after its UI script was renamed (`train_ui.py` → `train_ui_ctk.py`) - thanks to @NeuralFault! -### Performance -- Dragging an image layer with the **Move** tool in the layered mask editor now re-renders only the dragged layer instead of re-compositing every layer on each pointer move, so dragging stays smooth in multi-layer projects -- Faster color-mask extraction for regional prompting — a 1024×1024 canvas with six regions previously did over six million dictionary lookups per extraction -- Fixed a small native memory leak while drawing with the mouse (a path object per frame was never freed), and removed a redundant full-canvas scan after every paint-bucket fill -### Security -- Bundled **ADetailer** model downloads now point at a fixed Hugging Face revision instead of the repository's moving `main` branch, so an upstream re-upload can't change what you get - thanks to @ungrav! -### Supporters -#### 🌟 Visionaries -The prompt queue has been one of our most requested features ever, and builds like this only happen because our Visionaries give us the freedom to take them on. Thank you **Waterclouds**, **MrMxyzptlk12836**, **Psilocyfer18731**, **bluepopsicle**, **Ibixat**, **Droolguy**, **KalAbaddon**, **LG**, **snotty**, **whudunit**, **cusalapapen1481**, **moon_milky2843**, **SkynetFuture**, and **sn3232323233350** for standing behind us build after build. And to our three newest Visionaries, **tarekk071223**, **CC**, and **SnooSnooEternal**: welcome aboard, it means the world to have you with us. 💛 - -## v2.17.0-dev.1 -### Added -#### New Feature: 🤗 Live HuggingFace Model Browser -- Reworked the HuggingFace tab into a full browser instead of a fixed list of links — the curated picks are still there as the default view: - - Search HuggingFace for models right from the tab, sorted by downloads, likes, or most recently updated - - Paste a repository link to browse all of its files directly - - Browse files as a flat list or a folder tree, with a quick name filter and a **Hide installed** toggle - - Choose where each file goes (auto-detected and editable), or send a whole multi-file model such as a diffusers repo into one folder with its structure intact - - Select multiple files at once and see the total download size before you start, with a warning if there isn't enough free space - - Gated or private repositories work once you add a HuggingFace token in **Settings → Accounts** -- Added Inference support for the **Wan 2.2 14B** mixture-of-experts video models. Enable **Dual expert (high / low noise)** from the Wan model card's options menu (⚙️) to load a second low-noise expert alongside the high-noise model; generation then runs the two-pass high-noise → low-noise sampling the 14B architecture expects, switching at a configurable **Boundary** (the fraction of steps handled by the high-noise expert, default 0.5). Works in both Wan Text to Video and Image to Video, and leaving the low-noise slot empty keeps the existing single-model Wan behavior unchanged - - When you pick a model that looks like one half of a 14B expert pair (e.g. `wan2.2_t2v_high_noise_14B`), the card offers a one-click prompt to enable the second expert and auto-selects its matching low-noise counterpart - - The high-noise and low-noise model pickers sit together at the top as a labeled pair, with Precision, VAE, Text Encoder and Shift tucked into an **Advanced** section to keep the card approachable - - Added a separate **Low-Noise LoRAs** list so per-expert speed LoRAs (such as Wan 2.2-Lightning's high/low-noise pair) land on the correct model — the **High-Noise LoRAs** list applies to the high-noise/primary model, and the new Low-Noise LoRAs list applies to the low-noise expert - - Added hover tooltips to the Wan model card fields (Precision, VAE, Text Encoder, Shift, Low-Noise, Boundary) explaining what each one does -- Added a **gallery picker** for the Inference "+" new-tab button, grouping the project types into **Image / Video / Legacy** sections with icons and descriptions instead of a flat dropdown. The redundant standalone Flux text-to-image and SVD image-to-video types now live under **Legacy** so existing projects still open, without cluttering the list for new users - - Prefer the old menu? A **Compact New Tab Menu** toggle under Settings → Inference (and a quick link at the bottom of the picker dialog) switches the "+" button back to the fast dropdown -### Changed -- **Windows builds are now code-signed.** The portable executable is signed with a verified certificate, so Windows SmartScreen no longer flags Stability Matrix as from an unknown publisher. You may still see a SmartScreen prompt on the first releases while the certificate builds reputation, but those warnings will taper off as more people run signed builds -- **Generate now auto-resumes after launching ComfyUI.** If you press Generate in Inference while ComfyUI isn't connected and choose to launch it from the connection prompt, the queued generation now waits for startup and runs on its own once connected - no need to press Generate a second time -### Fixed -- Fixed the Inference **VAE** dropdown listing models in a seemingly random order, and the **Text Encoder** / **CLIP Vision** dropdowns occasionally reordering as the list finished loading; all three now sort alphabetically, with **Default** pinned to the top of the VAE list -- Model dropdowns no longer reserve space for a thumbnail when the selected model has no preview image, so names are no longer squished into a narrow strip -- Fixed [#1666](https://github.com/LykosAI/StabilityMatrix/issues/1666) - AppImage builds creating a broken `.desktop` entry (`NoDisplay=true`, missing icon) that never showed up in the application launcher and reverted any manual edits on the next launch. AppImage runs now write a correct `.desktop` entry with the extracted app icon so Stability Matrix appears in your launcher/menu, and report a matching `WM_CLASS` so the running window shows the Stability Matrix icon in the dock/taskbar instead of a generic one. Only applies to AppImage runs; deb/rpm/flatpak installs keep their package-managed entries -- Fixed `stabilitymatrix://` deep links (e.g. CivitAI "Download with Stability Matrix" buttons) being ignored on Linux AppImage builds — the URI is now forwarded to the running instance and the download starts, instead of just opening another window -### Performance -- The **Checkpoints** and **Outputs** galleries now load thumbnails at display size instead of full resolution, using far less memory and scrolling much more smoothly with large libraries -- The **CivitAI** and **OpenModelDB** browsers now keep card images in memory, so scrolling back over models you've already seen no longer re-loads them from disk -- Lightened the CivitAI model cards so they render faster while scrolling -### Supporters -#### 🌟 Visionaries -This build leans hard into what's next: a live HuggingFace browser and Wan 2.2 video generation. None of that exploration happens without our Visionaries giving us the room to chase it. So a huge thank you to **Waterclouds**, **MrMxyzptlk12836**, **Psilocyfer18731**, **bluepopsicle**, **Ibixat**, **Droolguy**, **KalAbaddon**, **LG**, **snotty**, **whudunit**, **cusalapapen1481**, and **moon_milky2843**. You make the experiments possible. And a big hello to **SkynetFuture** and **sn3232323233350**, who join the Visionary crew this time around. We're genuinely glad you're here. 💛 - ## v2.16.3 ### Changed - Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) — with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs @@ -151,7 +16,6 @@ This build leans hard into what's next: a live HuggingFace browser and Wan 2.2 v - Fixed [#1705](https://github.com/LykosAI/StabilityMatrix/issues/1705) - the **Model Browser** showing results in a scrambled order after refreshing with the **Search** button - Fixed [#1715](https://github.com/LykosAI/StabilityMatrix/issues/1715) - the app crashing on startup on Linux when the models folder contains folders or files differing only in letter case (e.g. `unet` and `Unet`, also [#1149](https://github.com/LykosAI/StabilityMatrix/issues/1149)/[#1357](https://github.com/LykosAI/StabilityMatrix/issues/1357)) — models in differently-cased folders now show up in the Checkpoint Manager instead of being silently skipped, and a model index error can no longer take down the app ->>>>>>> bc144b3d (Merge pull request #1330 from ionite34/fix/trainer-install-updates) ## v2.16.2 > Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.16.2](https://docs.lykos.ai/stability-matrix/release-notes/2.16.2) ### Added From 218e57bc876674a5cdd1cfa0b23c8eb9064accd6 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Aug 2026 22:04:47 -0700 Subject: [PATCH 03/27] Merge pull request #1331 from ionite34/fix/model-browser-bash Model browser bug bash: wrong-file downloads, '#' preview breakage, search-order scramble (cherry picked from commit be957b3f6201added2dcff777315e6c2f424fc04) --- .../Converters/FileUriConverter.cs | 24 +++- .../Services/ModelImportService.cs | 5 +- .../CivitAiBrowserViewModel.cs | 8 +- StabilityMatrix.Core/Models/Api/CivitFile.cs | 44 +++++++ .../Converters/FileUriConverterTests.cs | 74 +++++++++++ StabilityMatrix.Tests/Core/CivitFileTests.cs | 124 ++++++++++++++++++ 6 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 StabilityMatrix.Tests/Avalonia/Converters/FileUriConverterTests.cs create mode 100644 StabilityMatrix.Tests/Core/CivitFileTests.cs diff --git a/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs b/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs index c95d547f4..5d4d2473c 100644 --- a/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs +++ b/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs @@ -1,6 +1,6 @@ using System.Globalization; +using System.IO; using Avalonia.Data.Converters; -using StabilityMatrix.Core.Extensions; namespace StabilityMatrix.Avalonia.Converters; @@ -14,12 +14,22 @@ public class FileUriConverter : IValueConverter return null; } - return value switch + var str = value switch { - string str when str.StartsWith("avares://") => new Uri(str), - string str when (str.StartsWith("https://") || str.StartsWith("http://")) => new Uri(str), - string str => new Uri("file://" + str), - IFormattable formattable => new Uri("file://" + formattable.ToString(null, culture)), + string s => s, + IFormattable formattable => formattable.ToString(null, culture), + _ => null, + }; + + return str switch + { + null or "" => null, + _ when str.StartsWith("avares://") => new Uri(str), + _ when str.StartsWith("https://") || str.StartsWith("http://") => new Uri(str), + // Raw absolute file path: Uri's file-path parsing escapes reserved characters + // ("#", "%", spaces). Prepending "file://" instead would parse "#" as a fragment + // delimiter and truncate the path. + _ when Path.IsPathRooted(str) => new Uri(str), _ => null, }; } @@ -29,7 +39,7 @@ public class FileUriConverter : IValueConverter { if (targetType == typeof(string) && value is Uri uri) { - return uri.ToString().StripStart("file://"); + return uri.IsFile ? uri.LocalPath : uri.ToString(); } return null; diff --git a/StabilityMatrix.Avalonia/Services/ModelImportService.cs b/StabilityMatrix.Avalonia/Services/ModelImportService.cs index ddf6fc511..60bfa34c6 100644 --- a/StabilityMatrix.Avalonia/Services/ModelImportService.cs +++ b/StabilityMatrix.Avalonia/Services/ModelImportService.cs @@ -188,7 +188,10 @@ public async Task DoImport( var previewImagePath = await SavePreviewImage(modelVersion, downloadPath); // Create tracked download - var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath); + var download = trackedDownloadService.NewDownload( + modelFile.GetFileSpecificDownloadUrl(), + downloadPath + ); // Add hash info download.ExpectedHashSha256 = modelFile.Hashes.SHA256; diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs index bf5460efa..0a6854e05 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs @@ -697,7 +697,13 @@ private void UpdateModelCards(List? models, bool addCards = false) return; } - var startIndex = modelCache.Count; + // A replacement page restarts ordering at zero; an appended page continues after the + // highest existing order. Count is not a valid order base — it lags the max order + // whenever a fresh search replaces an uncleared cache (Search button re-click) or an + // append is de-duplicated, handing out colliding orders that scramble the sorted view. + var startIndex = addCards + ? modelCache.Items.Select(static ov => ov.Order).DefaultIfEmpty(-1).Max() + 1 + : 0; var modelsToAdd = models.Select((m, i) => new OrderedValue(startIndex + i, m)); diff --git a/StabilityMatrix.Core/Models/Api/CivitFile.cs b/StabilityMatrix.Core/Models/Api/CivitFile.cs index 18ace08c0..6970f4f12 100644 --- a/StabilityMatrix.Core/Models/Api/CivitFile.cs +++ b/StabilityMatrix.Core/Models/Api/CivitFile.cs @@ -50,4 +50,48 @@ public FileSizeType FullFilesSize } public string DisplayName => Path.GetFileNameWithoutExtension(Name); + + /// + /// Gets pinned to this specific file via a fileId query parameter. + /// CivitAI resolves /api/download/models/{versionId} URLs without a fileId by + /// preference-scoring the version's files against the metadata query parameters (format/size/fp), + /// which serves a different file of the same version when several files share the same metadata. + /// Pinning the file id makes the server look the file up directly instead. + /// Non-CivitAI URLs (e.g. raw storage URLs from the tRPC fallback) are returned unchanged. + /// + public string GetFileSpecificDownloadUrl() + { + if ( + Id <= 0 + || string.IsNullOrEmpty(DownloadUrl) + || !Uri.TryCreate(DownloadUrl, UriKind.Absolute, out var uri) + || !uri.Host.Equals("civitai.com", StringComparison.OrdinalIgnoreCase) + || !uri.AbsolutePath.StartsWith("/api/download/", StringComparison.OrdinalIgnoreCase) + ) + { + return DownloadUrl; + } + + var query = uri.Query.TrimStart('?'); + var hasFileId = query + .Split('&', StringSplitOptions.RemoveEmptyEntries) + .Any(p => p.StartsWith("fileId=", StringComparison.OrdinalIgnoreCase)); + if (hasFileId) + { + return DownloadUrl; + } + + // Insert ahead of any fragment so the parameter stays in the query string + var fragmentIndex = DownloadUrl.IndexOf('#'); + var baseUrl = fragmentIndex >= 0 ? DownloadUrl[..fragmentIndex] : DownloadUrl; + var fragment = fragmentIndex >= 0 ? DownloadUrl[fragmentIndex..] : string.Empty; + + var separator = query.Length == 0 ? "?" : "&"; + if (query.Length == 0) + { + baseUrl = baseUrl.TrimEnd('?'); + } + + return $"{baseUrl}{separator}fileId={Id}{fragment}"; + } } diff --git a/StabilityMatrix.Tests/Avalonia/Converters/FileUriConverterTests.cs b/StabilityMatrix.Tests/Avalonia/Converters/FileUriConverterTests.cs new file mode 100644 index 000000000..31b322a7c --- /dev/null +++ b/StabilityMatrix.Tests/Avalonia/Converters/FileUriConverterTests.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using StabilityMatrix.Avalonia.Converters; + +namespace StabilityMatrix.Tests.Avalonia.Converters; + +[TestClass] +public class FileUriConverterTests +{ + private readonly FileUriConverter converter = new(); + + [TestMethod] + public void Convert_PathWithHashAndSpaces_LocalPathRoundTrips() + { + // '#' from user file name patterns must not be parsed as a Uri fragment + var path = Path.Combine(Path.GetTempPath(), "cool #model v2.preview.png"); + + var result = converter.Convert(path, typeof(Uri), null, CultureInfo.InvariantCulture); + + var uri = result as Uri; + Assert.IsNotNull(uri); + Assert.AreEqual("file", uri.Scheme); + Assert.AreEqual(path, uri.LocalPath); + } + + [TestMethod] + public void Convert_PathWithPercent_LocalPathRoundTrips() + { + var path = Path.Combine(Path.GetTempPath(), "model 50%25.preview.png"); + + var result = converter.Convert(path, typeof(Uri), null, CultureInfo.InvariantCulture); + + var uri = result as Uri; + Assert.IsNotNull(uri); + Assert.AreEqual(path, uri.LocalPath); + } + + [TestMethod] + public void Convert_HttpUrl_PassesThrough() + { + const string url = "https://example.org/images/1.png"; + + var result = converter.Convert(url, typeof(Uri), null, CultureInfo.InvariantCulture); + + Assert.AreEqual(new Uri(url), result); + } + + [TestMethod] + public void Convert_AvaresUrl_PassesThrough() + { + const string url = "avares://StabilityMatrix.Avalonia/Assets/noimage.png"; + + var result = converter.Convert(url, typeof(Uri), null, CultureInfo.InvariantCulture); + + Assert.AreEqual(new Uri(url), result); + } + + [TestMethod] + public void Convert_NullOrEmpty_ReturnsNull() + { + Assert.IsNull(converter.Convert(null, typeof(Uri), null, CultureInfo.InvariantCulture)); + Assert.IsNull(converter.Convert("", typeof(Uri), null, CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void ConvertBack_FileUri_ReturnsLocalPath() + { + var path = Path.Combine(Path.GetTempPath(), "cool #model v2.preview.png"); + var uri = new Uri(path); + + var result = converter.ConvertBack(uri, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.AreEqual(path, result); + } +} diff --git a/StabilityMatrix.Tests/Core/CivitFileTests.cs b/StabilityMatrix.Tests/Core/CivitFileTests.cs new file mode 100644 index 000000000..cc80a96d2 --- /dev/null +++ b/StabilityMatrix.Tests/Core/CivitFileTests.cs @@ -0,0 +1,124 @@ +using StabilityMatrix.Core.Models.Api; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class CivitFileTests +{ + [TestMethod] + public void GetFileSpecificDownloadUrl_MetadataStyleUrl_AppendsFileId() + { + var file = new CivitFile + { + Id = 3054868, + DownloadUrl = + "https://civitai.com/api/download/models/3174361?type=Model&format=SafeTensor&fp=fp32", + }; + + Assert.AreEqual( + "https://civitai.com/api/download/models/3174361?type=Model&format=SafeTensor&fp=fp32&fileId=3054868", + file.GetFileSpecificDownloadUrl() + ); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_BareVersionUrl_AppendsFileId() + { + var file = new CivitFile + { + Id = 3054867, + DownloadUrl = "https://civitai.com/api/download/models/3174361", + }; + + Assert.AreEqual( + "https://civitai.com/api/download/models/3174361?fileId=3054867", + file.GetFileSpecificDownloadUrl() + ); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_FileIdSubstringOfOtherParam_StillAppendsFileId() + { + var file = new CivitFile + { + Id = 3054868, + DownloadUrl = "https://civitai.com/api/download/models/3174361?notfileId=5", + }; + + Assert.AreEqual( + "https://civitai.com/api/download/models/3174361?notfileId=5&fileId=3054868", + file.GetFileSpecificDownloadUrl() + ); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_UrlWithFragment_InsertsFileIdBeforeFragment() + { + var file = new CivitFile + { + Id = 3054868, + DownloadUrl = "https://civitai.com/api/download/models/3174361#section", + }; + + Assert.AreEqual( + "https://civitai.com/api/download/models/3174361?fileId=3054868#section", + file.GetFileSpecificDownloadUrl() + ); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_UrlAlreadyHasFileId_Unchanged() + { + var file = new CivitFile + { + Id = 3054868, + DownloadUrl = "https://civitai.com/api/download/models/3174361?fileId=3054868", + }; + + Assert.AreEqual( + "https://civitai.com/api/download/models/3174361?fileId=3054868", + file.GetFileSpecificDownloadUrl() + ); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_NonCivitaiUrl_Unchanged() + { + // Raw storage URL as returned by the tRPC fallback — must pass through untouched + // since extra query parameters would break a presigned URL. + var file = new CivitFile + { + Id = 123, + DownloadUrl = "https://storage.example.org/bucket/model.safetensors?sig=abc", + }; + + Assert.AreEqual( + "https://storage.example.org/bucket/model.safetensors?sig=abc", + file.GetFileSpecificDownloadUrl() + ); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_NonDownloadCivitaiUrl_Unchanged() + { + var file = new CivitFile { Id = 123, DownloadUrl = "https://civitai.com/models/2804527" }; + + Assert.AreEqual("https://civitai.com/models/2804527", file.GetFileSpecificDownloadUrl()); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_MissingFileId_Unchanged() + { + var file = new CivitFile { Id = 0, DownloadUrl = "https://civitai.com/api/download/models/3174361" }; + + Assert.AreEqual("https://civitai.com/api/download/models/3174361", file.GetFileSpecificDownloadUrl()); + } + + [TestMethod] + public void GetFileSpecificDownloadUrl_EmptyUrl_Unchanged() + { + var file = new CivitFile { Id = 123, DownloadUrl = string.Empty }; + + Assert.AreEqual(string.Empty, file.GetFileSpecificDownloadUrl()); + } +} From 4ec5adb45faac24b176db13757ed1c78b1adc013 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Aug 2026 22:09:09 -0700 Subject: [PATCH 04/27] Merge pull request #1332 from ionite34/chore/uv-0.12.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump bundled uv 0.9.30 → 0.12.5 (cherry picked from commit d33a761b954893a4d4651df4e3921e653396a357) --- StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs | 6 +++--- .../Helpers/WindowsPrerequisiteHelper.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs index 356a9c141..5d1250493 100644 --- a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs @@ -35,9 +35,9 @@ IPyInstallationManager pyInstallationManager private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string UvMacDownloadUrl = - "https://github.com/astral-sh/uv/releases/download/0.9.30/uv-aarch64-apple-darwin.tar.gz"; + "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-apple-darwin.tar.gz"; private const string UvLinuxDownloadUrl = - "https://github.com/astral-sh/uv/releases/download/0.9.30/uv-x86_64-unknown-linux-gnu.tar.gz"; + "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-gnu.tar.gz"; private DirectoryPath HomeDir => settingsManager.LibraryDir; private DirectoryPath AssetsDir => HomeDir.JoinDir("Assets"); @@ -75,7 +75,7 @@ private bool IsPythonVersionInstalled(PyVersion version) => // Cached store of whether or not git is installed private bool? isGitInstalled; - private string ExpectedUvVersion => "0.9.30"; + private string ExpectedUvVersion => "0.12.5"; public bool IsVcBuildToolsInstalled => false; public bool IsHipSdkInstalled => false; diff --git a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs index 459aa1618..99fb53929 100644 --- a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs @@ -56,7 +56,7 @@ IPyInstallationManager pyInstallationManager private const string PythonLibsDownloadUrl = "https://cdn.lykos.ai/python_libs_for_sage.zip"; private const string UvWindowsDownloadUrl = - "https://github.com/astral-sh/uv/releases/download/0.9.30/uv-x86_64-pc-windows-msvc.zip"; + "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-pc-windows-msvc.zip"; private string HomeDir => settingsManager.LibraryDir; @@ -122,7 +122,7 @@ private string GetPythonLibraryZipPath(PyVersion version) => private string UvExtractPath => Path.Combine(AssetsDir, "uv"); public string UvExePath => Path.Combine(UvExtractPath, "uv.exe"); public bool IsUvInstalled => File.Exists(UvExePath); - private string ExpectedUvVersion => "0.9.30"; + private string ExpectedUvVersion => "0.12.5"; // FFmpeg paths private string FfmpegDownloadPath => Path.Combine(AssetsDir, "ffmpeg.zip"); From abf236be684623109b61101110df3144574e1f67 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Aug 2026 23:16:27 -0700 Subject: [PATCH 05/27] Merge pull request #1333 from ionite34/fix/case-conflicting-model-folders Fix crashes from case-conflicting model folders on case-sensitive filesystems (cherry picked from commit 3c4d04df2b5bd48d8494e8ffb795ef287a6e6bf8) --- .../Services/InferenceClientManager.cs | 23 ++- .../ViewModels/CheckpointsPageViewModel.cs | 21 ++- .../Database/LiteDbContext.cs | 18 +- .../Services/ModelIndexService.cs | 113 ++++++++++-- .../Core/ModelIndexCanonicalizationTests.cs | 171 ++++++++++++++++++ 5 files changed, 312 insertions(+), 34 deletions(-) create mode 100644 StabilityMatrix.Tests/Core/ModelIndexCanonicalizationTests.cs diff --git a/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs b/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs index c230ba22e..21b459c1a 100644 --- a/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs +++ b/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs @@ -349,14 +349,23 @@ ICompletionProvider completionProvider // when the selection model tries to enumerate selected items. Dispatcher.UIThread.Post(() => { - ResetSharedProperties(); - - if (IsConnected) + // Guarded: an exception here is otherwise unhandled on the Dispatcher and + // crashes the app. + try + { + ResetSharedProperties(); + + if (IsConnected) + { + LoadSharedPropertiesAsync() + .SafeFireAndForget(onException: ex => + logger.LogError(ex, "Error loading shared properties") + ); + } + } + catch (Exception e) { - LoadSharedPropertiesAsync() - .SafeFireAndForget(onException: ex => - logger.LogError(ex, "Error loading shared properties") - ); + logger.LogError(e, "Error resetting shared properties for Inference"); } }); }; diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs index 244b3313f..ecaddeb87 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs @@ -401,7 +401,7 @@ or nameof(SortConnectedModelsFirst) ) ); - Refresh().SafeFireAndForget(); + Refresh().SafeFireAndForget(ex => logger.LogError(ex, "Error refreshing model index")); EventManager.Instance.ModelIndexChanged += (_, _) => { @@ -409,11 +409,20 @@ or nameof(SortConnectedModelsFirst) // The ModelIndexChanged event may be raised from a background thread. Dispatcher.UIThread.Post(() => { - RefreshCategories(); - ModelsCache.EditDiff( - modelIndexService.ModelIndex.Values.SelectMany(x => x), - LocalModelFile.RelativePathConnectedModelInfoComparer - ); + // Guarded: an exception here is otherwise unhandled on the Dispatcher and + // crashes the app. + try + { + RefreshCategories(); + ModelsCache.EditDiff( + modelIndexService.ModelIndex.Values.SelectMany(x => x), + LocalModelFile.RelativePathConnectedModelInfoComparer + ); + } + catch (Exception ex) + { + logger.LogError(ex, "Error refreshing checkpoint categories"); + } }); }; diff --git a/StabilityMatrix.Core/Database/LiteDbContext.cs b/StabilityMatrix.Core/Database/LiteDbContext.cs index 3eb6a3a90..e8d8e3165 100644 --- a/StabilityMatrix.Core/Database/LiteDbContext.cs +++ b/StabilityMatrix.Core/Database/LiteDbContext.cs @@ -62,6 +62,15 @@ IOptions debugOptions lazyDatabase = new Lazy(CreateDatabase); } + /// + /// Collation for all databases: Ordinal, so keys differing only in case (possible for model + /// paths on case-sensitive file systems) never collide in unique indexes. + /// + private static Collation OrdinalCollation => + new(CultureInfo.InvariantCulture.LCID, CompareOptions.Ordinal); + + private static ConnectionString TempConnectionString => new(":temp:") { Collation = OrdinalCollation }; + private LiteDatabaseAsync CreateDatabase() { // Try at most twice: @@ -78,7 +87,7 @@ private LiteDatabaseAsync CreateDatabase() { if (debugOptions.TempDatabase) { - db = new LiteDatabaseAsync(":temp:"); + db = new LiteDatabaseAsync(TempConnectionString); RegisterRefs(); return db; } @@ -94,10 +103,7 @@ private LiteDatabaseAsync CreateDatabase() "Database collation is not Ordinal ({SortOption}), rebuilding...", sortOption ); - var options = new RebuildOptions - { - Collation = new Collation(CultureInfo.InvariantCulture.LCID, CompareOptions.Ordinal), - }; + var options = new RebuildOptions { Collation = OrdinalCollation }; db.RebuildAsync(options).GetAwaiter().GetResult(); } @@ -148,7 +154,7 @@ private LiteDatabaseAsync CreateDatabase() } // Fallback to temporary database - var tempDb = new LiteDatabaseAsync(":temp:"); + var tempDb = new LiteDatabaseAsync(TempConnectionString); RegisterRefs(); return tempDb; diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs index c7c8633fc..dd1d66686 100644 --- a/StabilityMatrix.Core/Services/ModelIndexService.cs +++ b/StabilityMatrix.Core/Services/ModelIndexService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; +using System.Globalization; using System.Text; using AsyncAwaitBestPractices; using AutoCtor; @@ -213,6 +214,68 @@ public Task RefreshIndex() return RefreshIndexParallelCore(); } + /// + /// Resolves a top-level models folder name to its . + /// Case-insensitive, so a folder whose casing diverges from the canonical name (possible on + /// case-sensitive file systems, e.g. "textencoders" next to "TextEncoders") still indexes as + /// its canonical type. Unmatched names resolve to . + /// + internal static SharedFolderType ParseSharedFolderType(string folderName) => + Enum.TryParse(folderName, ignoreCase: true, out var type) + ? type + : SharedFolderType.Unknown; + + /// + /// Filters models so no two entries collide on the + /// primary key under the database's collation. The main database uses Ordinal collation, where + /// distinct paths never collide; under a case-insensitive collation, paths differing only in + /// case (possible on case-sensitive file systems) would otherwise fail the bulk insert with a + /// duplicate key error. + /// + internal static IReadOnlyList DeduplicateForDbCollation( + IReadOnlyCollection models, + Collation collation, + ILogger logger + ) + { + var deduplicated = new List(models.Count); + var seenPaths = new HashSet(models.Count, GetKeyComparer(collation)); + + foreach (var model in models) + { + if (seenPaths.Add(model.RelativePath)) + { + deduplicated.Add(model); + } + else + { + logger.LogWarning( + "Skipping model {Path}: its path collides with an already indexed model " + + "under the database collation ({Collation})", + model.RelativePath, + collation + ); + } + } + + return deduplicated; + } + + /// + /// A with the same equality semantics as a LiteDB collation, + /// for detecting primary key collisions ahead of insert. + /// + internal static StringComparer GetKeyComparer(Collation collation) => + collation.SortOptions switch + { + CompareOptions.Ordinal => StringComparer.Ordinal, + CompareOptions.OrdinalIgnoreCase => StringComparer.OrdinalIgnoreCase, + var options => StringComparer.Create( + collation.Culture, + options.HasFlag(CompareOptions.IgnoreCase) + ), + }; + private async Task RefreshIndexCore() { if (!settingsManager.IsLibraryDirSet) @@ -255,7 +318,8 @@ private async Task RefreshIndexCore() StringSplitOptions.RemoveEmptyEntries )[0]; // Try Convert to enum - if (!Enum.TryParse(sharedFolderName, out var sharedFolderType)) + var sharedFolderType = ParseSharedFolderType(sharedFolderName); + if (sharedFolderType is SharedFolderType.Unknown) { continue; } @@ -341,14 +405,25 @@ private async Task RefreshIndexCore() // Insert to db as transaction stopwatch.Restart(); - using var db = await liteDbContext.Database.BeginTransactionAsync().ConfigureAwait(false); + var dbModels = DeduplicateForDbCollation(newIndexFlat, liteDbContext.Database.Collation, logger); + + try + { + using var db = await liteDbContext.Database.BeginTransactionAsync().ConfigureAwait(false); - var localModelFiles = db.GetCollection("LocalModelFiles")!; + var localModelFiles = db.GetCollection("LocalModelFiles")!; - await localModelFiles.DeleteAllAsync().ConfigureAwait(false); - await localModelFiles.InsertBulkAsync(newIndexFlat).ConfigureAwait(false); + await localModelFiles.DeleteAllAsync().ConfigureAwait(false); + await localModelFiles.InsertBulkAsync(dbModels).ConfigureAwait(false); - await db.CommitAsync().ConfigureAwait(false); + await db.CommitAsync().ConfigureAwait(false); + } + catch (Exception e) when (e is LiteException or LiteAsyncException) + { + // A failed persist must not propagate: callers await refreshes from UI contexts where + // an exception crashes the app, and the in-memory index above is already updated. + logger.LogError(e, "Failed to persist model index to database"); + } stopwatch.Stop(); var dbTime = stopwatch.Elapsed; @@ -415,10 +490,7 @@ private async Task RefreshIndexParallelCore() StringSplitOptions.RemoveEmptyEntries )[0]; // Try Convert to enum - if (!Enum.TryParse(sharedFolderName, out var sharedFolderType)) - { - sharedFolderType = SharedFolderType.Unknown; - } + var sharedFolderType = ParseSharedFolderType(sharedFolderName); // Since RelativePath is the database key, for LiteDB this is limited to 1021 bytes if (Encoding.UTF8.GetByteCount(relativePath) is var byteCount and > 1021) @@ -554,13 +626,24 @@ await liteDbContext // Insert to db as transaction stopwatch.Restart(); - using var db = await liteDbContext.Database.BeginTransactionAsync().ConfigureAwait(false); - var localModelFiles = db.GetCollection("LocalModelFiles")!; + var dbModels = DeduplicateForDbCollation(newIndexComplete, liteDbContext.Database.Collation, logger); - await localModelFiles.DeleteAllAsync().ConfigureAwait(false); - await localModelFiles.InsertBulkAsync(newIndexComplete).ConfigureAwait(false); + try + { + using var db = await liteDbContext.Database.BeginTransactionAsync().ConfigureAwait(false); + var localModelFiles = db.GetCollection("LocalModelFiles")!; - await db.CommitAsync().ConfigureAwait(false); + await localModelFiles.DeleteAllAsync().ConfigureAwait(false); + await localModelFiles.InsertBulkAsync(dbModels).ConfigureAwait(false); + + await db.CommitAsync().ConfigureAwait(false); + } + catch (Exception e) when (e is LiteException or LiteAsyncException) + { + // A failed persist must not propagate: callers await refreshes from UI contexts where + // an exception crashes the app, and the in-memory index above is already updated. + logger.LogError(e, "Failed to persist model index to database"); + } stopwatch.Stop(); var dbTime = stopwatch.Elapsed; diff --git a/StabilityMatrix.Tests/Core/ModelIndexCanonicalizationTests.cs b/StabilityMatrix.Tests/Core/ModelIndexCanonicalizationTests.cs new file mode 100644 index 000000000..24c4daf2d --- /dev/null +++ b/StabilityMatrix.Tests/Core/ModelIndexCanonicalizationTests.cs @@ -0,0 +1,171 @@ +using System.Globalization; +using LiteDB; +using Microsoft.Extensions.Logging.Abstractions; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Database; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Tests.Core; + +/// +/// Tests for model index canonicalization with case-conflicting model folder paths +/// (possible on case-sensitive file systems, GitHub issues #1149 / #1357 / #1715). +/// +[TestClass] +public class ModelIndexCanonicalizationTests +{ + private static readonly Collation OrdinalCollation = new( + CultureInfo.InvariantCulture.LCID, + CompareOptions.Ordinal + ); + + private static readonly Collation IgnoreCaseCollation = new( + CultureInfo.InvariantCulture.LCID, + CompareOptions.IgnoreCase + ); + + [TestMethod] + [DataRow("TextEncoders", SharedFolderType.TextEncoders)] + [DataRow("Lora", SharedFolderType.Lora)] + [DataRow("DiffusionModels", SharedFolderType.DiffusionModels)] + public void ParseSharedFolderType_CanonicalName_ResolvesType(string folderName, SharedFolderType expected) + { + Assert.AreEqual(expected, ModelIndexService.ParseSharedFolderType(folderName)); + } + + [TestMethod] + [DataRow("textencoders", SharedFolderType.TextEncoders)] + [DataRow("TEXTENCODERS", SharedFolderType.TextEncoders)] + [DataRow("lora", SharedFolderType.Lora)] + [DataRow("stablediffusion", SharedFolderType.StableDiffusion)] + public void ParseSharedFolderType_CaseVariantName_ResolvesType( + string folderName, + SharedFolderType expected + ) + { + Assert.AreEqual(expected, ModelIndexService.ParseSharedFolderType(folderName)); + } + + [TestMethod] + [DataRow("text_encoders")] + [DataRow("diffusion_models")] + [DataRow("unet")] + [DataRow("SomeRandomFolder")] + public void ParseSharedFolderType_UnmappedName_ResolvesUnknown(string folderName) + { + // ComfyUI-native folder names (underscored) and arbitrary folders are not aliased to + // shared folder types; they index as Unknown. Notably "diffusion_models" must stay + // Unknown: SwarmUI launch creates it as a junction of DiffusionModels inside the models + // directory, and typing both would double-list every model. + Assert.AreEqual(SharedFolderType.Unknown, ModelIndexService.ParseSharedFolderType(folderName)); + } + + [TestMethod] + public void DeduplicateForDbCollation_IgnoreCaseCollation_DropsCaseOnlyDuplicates() + { + var models = CaseConflictingModels(); + + var result = ModelIndexService.DeduplicateForDbCollation( + models, + IgnoreCaseCollation, + NullLogger.Instance + ); + + // The unet/Unet pair differs only in case and must collapse to one entry; + // text_encoders/TextEncoders differ by more than case and must both survive. + Assert.AreEqual(3, result.Count); + Assert.AreEqual( + 1, + result.Count(m => + string.Equals(m.RelativePath, "unet/model.gguf", StringComparison.OrdinalIgnoreCase) + ) + ); + Assert.IsTrue(result.Any(m => m.RelativePath == "text_encoders/clip_l.safetensors")); + Assert.IsTrue(result.Any(m => m.RelativePath == "TextEncoders/clip_l.safetensors")); + } + + [TestMethod] + public void DeduplicateForDbCollation_OrdinalCollation_KeepsCaseOnlyDistinctPaths() + { + var models = CaseConflictingModels(); + + var result = ModelIndexService.DeduplicateForDbCollation( + models, + OrdinalCollation, + NullLogger.Instance + ); + + // Ordinal collation treats case-only variants as distinct keys, so nothing is dropped + // (a case-sensitive file system can legitimately hold both files). + Assert.AreEqual(4, result.Count); + } + + [TestMethod] + public void DeduplicateForDbCollation_OutputInserts_UnderCaseInsensitiveDb() + { + // Default LiteDB collation is case-insensitive: raw case-conflicting paths are expected + // to fail the unique _id index (the #1357 / #1149 crash), while the deduplicated set + // must insert cleanly. + using var db = new LiteDatabase(":memory:"); + Assert.IsTrue( + db.Collation.SortOptions.HasFlag(CompareOptions.IgnoreCase), + "Premise: default LiteDB collation is case-insensitive" + ); + + var rawCollection = db.GetCollection("RawLocalModelFiles"); + Assert.ThrowsException(() => rawCollection.Insert(CaseConflictingModels())); + + var dedupedCollection = db.GetCollection("LocalModelFiles"); + var deduplicated = ModelIndexService.DeduplicateForDbCollation( + CaseConflictingModels(), + db.Collation, + NullLogger.Instance + ); + + dedupedCollection.Insert(deduplicated); + + Assert.AreEqual(deduplicated.Count, dedupedCollection.Count()); + } + + [TestMethod] + public void GetKeyComparer_MatchesCollationSemantics() + { + Assert.AreSame(StringComparer.Ordinal, ModelIndexService.GetKeyComparer(OrdinalCollation)); + Assert.AreSame( + StringComparer.OrdinalIgnoreCase, + ModelIndexService.GetKeyComparer( + new Collation(CultureInfo.InvariantCulture.LCID, CompareOptions.OrdinalIgnoreCase) + ) + ); + + var ignoreCaseComparer = ModelIndexService.GetKeyComparer(IgnoreCaseCollation); + Assert.IsTrue(ignoreCaseComparer.Equals("unet/model.gguf", "Unet/model.gguf")); + Assert.IsFalse( + ignoreCaseComparer.Equals("text_encoders/clip_l.safetensors", "TextEncoders/clip_l.safetensors") + ); + } + + private static List CaseConflictingModels() => + [ + new LocalModelFile + { + RelativePath = "unet/model.gguf", + SharedFolderType = SharedFolderType.Unknown, + }, + new LocalModelFile + { + RelativePath = "Unet/model.gguf", + SharedFolderType = SharedFolderType.Unknown, + }, + new LocalModelFile + { + RelativePath = "text_encoders/clip_l.safetensors", + SharedFolderType = SharedFolderType.Unknown, + }, + new LocalModelFile + { + RelativePath = "TextEncoders/clip_l.safetensors", + SharedFolderType = SharedFolderType.TextEncoders, + }, + ]; +} From 65eb6cffe1eb933b4555a8f306a9b7602f2331d3 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Aug 2026 23:31:05 -0700 Subject: [PATCH 06/27] Merge pull request #1338 from ionite34/chore/jenkins-linux-builder-label Point CI at linux-builder role label instead of agent hostname (cherry picked from commit 6481f4693237d6024bdc55f1c5ee0749ebb3864f) --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 8cd19deae..95bf7d7c5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,4 +1,4 @@ -node("Diligence") { +node("linux-builder") { def repoName = "StabilityMatrix" def author = "ionite34" def version = "" From 30fc2a2f0b762f8fbda6a0e269880980c3939d3f Mon Sep 17 00:00:00 2001 From: JT Date: Sat, 15 Aug 2026 19:17:44 -0700 Subject: [PATCH 07/27] Merge pull request #1340 from ionite34/fix/model-update-check fix: stop flagging phantom model updates in Checkpoint Manager (cherry picked from commit 97a87bf7e29b7016691d6dcc4b81c5847e801904) --- CHANGELOG.md | 3 + StabilityMatrix.Core/Helper/ModelFinder.cs | 8 +- .../Models/Api/CivitModelVersion.cs | 12 +- .../Services/ModelIndexService.cs | 114 ++++++-- .../Core/ModelIndexServiceTests.cs | 255 +++++++++++++++++- 5 files changed, 366 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6834521..e8a73d709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed [#1703](https://github.com/LykosAI/StabilityMatrix/issues/1703) - model cover/preview images not displaying when your **File Name Pattern** contains a `#` - Fixed [#1705](https://github.com/LykosAI/StabilityMatrix/issues/1705) - the **Model Browser** showing results in a scrambled order after refreshing with the **Search** button - Fixed [#1715](https://github.com/LykosAI/StabilityMatrix/issues/1715) - the app crashing on startup on Linux when the models folder contains folders or files differing only in letter case (e.g. `unet` and `Unet`, also [#1149](https://github.com/LykosAI/StabilityMatrix/issues/1149)/[#1357](https://github.com/LykosAI/StabilityMatrix/issues/1357)) — models in differently-cased folders now show up in the Checkpoint Manager instead of being silently skipped, and a model index error can no longer take down the app +- Fixed [#1716](https://github.com/LykosAI/StabilityMatrix/issues/1716) - phantom **Update Available** badges on embeddings, VAEs, upscalers, and ControlNets — the update check didn't count those file types as evidence of being up to date, so it flagged them all forever; wrong badges clear on the next check +- Fixed early access versions no longer being detected — CivitAI now marks them via a deadline field while reporting them as public, which broke the early-access update badge and the Model Browser's **Hide Early Access Models** filter +- Fixed multi-architecture models (e.g. Illustrious | Pony | NoobAI in one listing) counting a release for a different architecture as an update — update checks now compare within your installed version's base model, so the update and early-access badges reflect the actual successor on your track ## v2.16.2 > Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.16.2](https://docs.lykos.ai/stability-matrix/release-notes/2.16.2) diff --git a/StabilityMatrix.Core/Helper/ModelFinder.cs b/StabilityMatrix.Core/Helper/ModelFinder.cs index 72caf3304..bb3c1b734 100644 --- a/StabilityMatrix.Core/Helper/ModelFinder.cs +++ b/StabilityMatrix.Core/Helper/ModelFinder.cs @@ -56,8 +56,8 @@ public ModelFinder(ILiteDbContext liteDbContext, ICivitApi civitApi) // VersionResponse is not actually the full data of ModelVersion, so find it again var version = model.ModelVersions!.First(version => version.Id == versionResponse.Id); - var file = versionResponse.Files.FirstOrDefault( - file => hashBlake3.Equals(file.Hashes.BLAKE3, StringComparison.OrdinalIgnoreCase) + var file = versionResponse.Files.FirstOrDefault(file => + hashBlake3.Equals(file.Hashes.BLAKE3, StringComparison.OrdinalIgnoreCase) ); // Archived models do not have files @@ -122,7 +122,7 @@ public async Task> FindRemoteModelsById(IEnumerable { CommaSeparatedModelIds = string.Join(",", batch), Nsfw = "true", - Query = string.Empty + Query = string.Empty, } ) .ConfigureAwait(false); @@ -134,7 +134,7 @@ public async Task> FindRemoteModelsById(IEnumerable } catch (Exception e) { - Logger.Error("Error while finding remote models by id: {Error}", e.Message); + Logger.Error(e, "Error while finding remote models by id"); } } diff --git a/StabilityMatrix.Core/Models/Api/CivitModelVersion.cs b/StabilityMatrix.Core/Models/Api/CivitModelVersion.cs index a71a83584..10991b633 100644 --- a/StabilityMatrix.Core/Models/Api/CivitModelVersion.cs +++ b/StabilityMatrix.Core/Models/Api/CivitModelVersion.cs @@ -37,7 +37,17 @@ public class CivitModelVersion [JsonPropertyName("publishedAt")] public DateTimeOffset? PublishedAt { get; set; } + [JsonPropertyName("earlyAccessDeadline")] + public DateTimeOffset? EarlyAccessDeadline { get; set; } + + /// + /// Whether the version is currently in early access. CivitAI signals this on current + /// API responses via a future while reporting + /// as "Public"; the availability check is kept for + /// responses that do use the explicit value. + /// [JsonIgnore] public bool IsEarlyAccess => - Availability?.Equals("EarlyAccess", StringComparison.OrdinalIgnoreCase) ?? false; + (Availability?.Equals("EarlyAccess", StringComparison.OrdinalIgnoreCase) ?? false) + || EarlyAccessDeadline > DateTimeOffset.UtcNow; } diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs index dd1d66686..b7067c6f6 100644 --- a/StabilityMatrix.Core/Services/ModelIndexService.cs +++ b/StabilityMatrix.Core/Services/ModelIndexService.cs @@ -721,8 +721,6 @@ public async Task CheckModelsForUpdateAsync() return; } - lastUpdateCheck = DateTimeOffset.UtcNow; - var installedHashes = ModelIndexBlake3Hashes; var dbModels = ( await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) ?? [] @@ -731,10 +729,26 @@ await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) ?? [] var ids = dbModels .Where(x => x.ConnectedModelInfo?.ModelId != null) .Select(x => x.ConnectedModelInfo!.ModelId.Value) - .Distinct(); + .Distinct() + .ToList(); + + if (ids.Count == 0) + { + lastUpdateCheck = DateTimeOffset.UtcNow; + return; + } var remoteModels = (await modelFinder.FindRemoteModelsById(ids).ConfigureAwait(false)).ToList(); + // An empty result for a non-empty id set means the API was unreachable; keep the + // existing flags and leave the throttle window unconsumed so the next visit retries. + if (remoteModels.Count == 0) + { + return; + } + + lastUpdateCheck = DateTimeOffset.UtcNow; + // update the civitmodels cache with this new result await liteDbContext.UpsertCivitModelAsync(remoteModels).ConfigureAwait(false); @@ -746,20 +760,12 @@ await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) ?? [] var remoteModel = remoteModels.FirstOrDefault(m => m.Id == dbModel.ConnectedModelInfo!.ModelId); - var latestVersion = remoteModel?.ModelVersions?.FirstOrDefault(); - - if (latestVersion?.Files is not { } latestVersionFiles) - { + // Absent from the response (removed from CivitAI or a partially failed batch): + // indeterminate, so keep the previous flags rather than inventing a change. + if (remoteModel == null) continue; - } - - var latestHashes = latestVersionFiles - .Where(f => f.Type.IsModelWeights()) - .Select(f => f.Hashes.BLAKE3) - .Where(hash => hash is not null) - .ToList(); - dbModel.HasUpdate = !latestHashes.Any(hash => installedHashes.Contains(hash!)); + dbModel.HasUpdate = ComputeHasUpdate(dbModel, remoteModel, installedHashes); dbModel.HasEarlyAccessUpdateOnly = GetHasEarlyAccessUpdateOnly(dbModel, remoteModel); dbModel.LastUpdateCheck = DateTimeOffset.UtcNow; dbModel.LatestModelInfo = remoteModel; @@ -770,6 +776,69 @@ await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) ?? [] await LoadFromDbAsync().ConfigureAwait(false); } + /// + /// Decides whether a newer installable version of a model exists on CivitAI. Prefers hash + /// evidence (any of the latest version's files already installed somewhere in the library); + /// falls back to version-id position when the latest version publishes no hashable files. + /// Indeterminate cases resolve to false so we never show an update badge we can't substantiate. + /// Multi-architecture models publish parallel version tracks in one list (v7_Illustrious, + /// v1_Anima, v4_Pony, ...), so comparisons stay within the installed file's base-model track — + /// a release for a different architecture is a cross-grade, not an update. + /// + private static bool ComputeHasUpdate( + LocalModelFile model, + CivitModel remoteModel, + IReadOnlySet installedHashes + ) + { + if ( + FilterToInstalledTrack(remoteModel.ModelVersions, model.ConnectedModelInfo?.BaseModel) + is not { Count: > 0 } versions + ) + return false; + + var latestVersion = versions[0]; + var installedVersionId = model.ConnectedModelInfo?.VersionId; + + if (installedVersionId != null && installedVersionId == latestVersion.Id) + return false; + + var latestHashes = (latestVersion.Files ?? []) + .Where(f => f.Type.IsDownloadableModelFile()) + .Select(f => f.Hashes?.BLAKE3) + .Where(hash => !string.IsNullOrEmpty(hash)) + .ToList(); + + if (latestHashes.Count > 0) + { + return !latestHashes.Any(hash => installedHashes.Contains(hash!)); + } + + // No hash evidence — flag only when the installed version verifiably sits below the + // latest in the published version list. + return installedVersionId != null && versions.FindIndex(v => v.Id == installedVersionId.Value) > 0; + } + + /// + /// Narrows a model's version list to the installed file's base-model track. Falls back to + /// the full list when the installed base model is unknown or matches nothing (e.g. the + /// track was renamed or delisted) rather than reporting nothing forever. + /// + private static List? FilterToInstalledTrack( + List? versions, + string? installedBaseModel + ) + { + if (versions is null || string.IsNullOrWhiteSpace(installedBaseModel)) + return versions; + + var track = versions + .Where(v => string.Equals(v.BaseModel, installedBaseModel, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return track.Count > 0 ? track : versions; + } + public async Task UpsertModelAsync(LocalModelFile model) { await liteDbContext.LocalModelFiles.UpsertAsync(model).ConfigureAwait(false); @@ -785,7 +854,8 @@ private void OnModelIndexReset() private static HashSet CollectModelHashes(IEnumerable models) { - var hashes = new HashSet(); + // CivitAI reports BLAKE3 uppercase while locally computed hashes are lowercase + var hashes = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var model in models) { if (model.ConnectedModelInfo?.Hashes?.BLAKE3 is { } hashBlake3) @@ -827,7 +897,10 @@ private static bool GetHasEarlyAccessUpdateOnly(LocalModelFile model, CivitModel if (!model.HasUpdate || !model.HasCivitMetadata) return false; - var versions = remoteModel?.ModelVersions; + var versions = FilterToInstalledTrack( + remoteModel?.ModelVersions, + model.ConnectedModelInfo?.BaseModel + ); if (versions == null || versions.Count == 0) return false; @@ -836,9 +909,12 @@ private static bool GetHasEarlyAccessUpdateOnly(LocalModelFile model, CivitModel return false; var installedIndex = versions.FindIndex(version => version.Id == installedVersionId.Value); - if (installedIndex <= 0) + if (installedIndex == 0) return false; - return versions.Take(installedIndex).All(version => version.IsEarlyAccess); + // When the installed version no longer appears in the published list, every published + // version is a potential update; the badge is early-access-only when all of them are. + var newerVersions = installedIndex > 0 ? versions.Take(installedIndex) : versions; + return newerVersions.All(version => version.IsEarlyAccess); } } diff --git a/StabilityMatrix.Tests/Core/ModelIndexServiceTests.cs b/StabilityMatrix.Tests/Core/ModelIndexServiceTests.cs index 5dbd15de8..1217dde91 100644 --- a/StabilityMatrix.Tests/Core/ModelIndexServiceTests.cs +++ b/StabilityMatrix.Tests/Core/ModelIndexServiceTests.cs @@ -83,6 +83,240 @@ public void GetHasEarlyAccessUpdateOnly_ReturnsFalse_WhenInstalledVersionIsNotIn Assert.IsFalse(result); } + [TestMethod] + public void GetHasEarlyAccessUpdateOnly_ReturnsTrue_WhenInstalledVersionMissingAndAllVersionsAreEarlyAccess() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: true); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 300, isEarlyAccess: true), + CreateVersion(id: 200, isEarlyAccess: true) + ); + + var result = InvokeGetHasEarlyAccessUpdateOnly(model, remoteModel); + + Assert.IsTrue(result); + } + + [TestMethod] + public void GetHasEarlyAccessUpdateOnly_TrackAware_IgnoresOtherArchitectures() + { + // Mirrors civitai model 1377820: the installed Illustrious file's real update (v7, + // early access) must not be masked by a newer public release for another architecture + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: true, baseModel: "Illustrious"); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 300, isEarlyAccess: true, baseModel: "Illustrious"), + CreateVersion(id: 250, isEarlyAccess: false, baseModel: "Anima"), + CreateVersion(id: 100, isEarlyAccess: false, baseModel: "Illustrious") + ); + + var result = InvokeGetHasEarlyAccessUpdateOnly(model, remoteModel); + + Assert.IsTrue(result); + } + + [TestMethod] + public void ComputeHasUpdate_IgnoresCrossArchitectureReleases() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false, baseModel: "Illustrious"); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 250, isEarlyAccess: false, "Anima", CreateFile(CivitFileType.Model, "anima1")), + CreateVersion( + id: 100, + isEarlyAccess: false, + "Illustrious", + CreateFile(CivitFileType.Model, "illu6") + ) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, ["illu6"]); + + Assert.IsFalse(result); + } + + [TestMethod] + public void ComputeHasUpdate_FlagsSameTrackUpdate() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false, baseModel: "Illustrious"); + var remoteModel = CreateRemoteModel( + CreateVersion( + id: 300, + isEarlyAccess: true, + "Illustrious", + CreateFile(CivitFileType.Model, "illu7") + ), + CreateVersion(id: 250, isEarlyAccess: false, "Anima", CreateFile(CivitFileType.Model, "anima1")), + CreateVersion( + id: 100, + isEarlyAccess: false, + "Illustrious", + CreateFile(CivitFileType.Model, "illu6") + ) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, ["illu6"]); + + Assert.IsTrue(result); + } + + [TestMethod] + public void IsEarlyAccess_TrueForFutureDeadline_DespitePublicAvailability() + { + // Current CivitAI responses report availability "Public" for early-access versions + // and signal the state via earlyAccessDeadline instead (observed on model 1377820) + var version = CreateVersion(id: 100, isEarlyAccess: false); + version.Availability = "Public"; + version.EarlyAccessDeadline = DateTimeOffset.UtcNow.AddDays(7); + + Assert.IsTrue(version.IsEarlyAccess); + } + + [TestMethod] + public void IsEarlyAccess_FalseForPastDeadline() + { + var version = CreateVersion(id: 100, isEarlyAccess: false); + version.Availability = "Public"; + version.EarlyAccessDeadline = DateTimeOffset.UtcNow.AddDays(-1); + + Assert.IsFalse(version.IsEarlyAccess); + } + + [TestMethod] + public void IsEarlyAccess_TrueForExplicitAvailability() + { + var version = CreateVersion(id: 100, isEarlyAccess: true); + + Assert.IsTrue(version.IsEarlyAccess); + } + + [TestMethod] + public void ComputeHasUpdate_ReturnsFalse_WhenLatestNonWeightFileIsInstalled() + { + // Embeddings/VAEs/upscalers publish files typed Negative/VAE/Upscaler rather than + // Model — these must count as installable evidence, not produce a permanent badge + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 100, isEarlyAccess: false, CreateFile(CivitFileType.Negative, "aabbcc")) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, ["aabbcc"]); + + Assert.IsFalse(result); + } + + [TestMethod] + public void ComputeHasUpdate_ReturnsFalse_WhenInstalledVersionIsLatestDespiteNoHashes() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 100, isEarlyAccess: false, CreateFile(CivitFileType.TrainingData, null)) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, []); + + Assert.IsFalse(result); + } + + [TestMethod] + public void ComputeHasUpdate_ReturnsTrue_WhenNoHashesAndInstalledVersionIsOlder() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 200, isEarlyAccess: false), + CreateVersion(id: 100, isEarlyAccess: false) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, []); + + Assert.IsTrue(result); + } + + [TestMethod] + public void ComputeHasUpdate_ReturnsFalse_WhenNoHashesAndInstalledVersionUnknown() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: true); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 300, isEarlyAccess: false), + CreateVersion(id: 200, isEarlyAccess: false) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, []); + + Assert.IsFalse(result); + } + + [TestMethod] + public void ComputeHasUpdate_ReturnsTrue_WhenLatestWeightsNotInstalled() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false); + var remoteModel = CreateRemoteModel( + CreateVersion(id: 200, isEarlyAccess: false, CreateFile(CivitFileType.Model, "ddeeff")), + CreateVersion(id: 100, isEarlyAccess: false, CreateFile(CivitFileType.Model, "aabbcc")) + ); + + var result = InvokeComputeHasUpdate(model, remoteModel, ["aabbcc"]); + + Assert.IsTrue(result); + } + + [TestMethod] + public void ComputeHasUpdate_ReturnsFalse_WhenRemoteModelHasNoVersions() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: true); + var remoteModel = CreateRemoteModel(); + + var result = InvokeComputeHasUpdate(model, remoteModel, []); + + Assert.IsFalse(result); + } + + [TestMethod] + public void CollectModelHashes_MatchesCaseInsensitively() + { + var model = CreateLocalModel(installedVersionId: 100, hasUpdate: false); + model.ConnectedModelInfo!.Hashes = new CivitFileHashes { BLAKE3 = "AABBCC" }; + + var method = typeof(ModelIndexService).GetMethod( + "CollectModelHashes", + BindingFlags.NonPublic | BindingFlags.Static + ); + + Assert.IsNotNull(method); + + var hashes = (HashSet)method.Invoke(null, [new[] { model }])!; + + Assert.IsTrue(hashes.Contains("aabbcc")); + } + + private static bool InvokeComputeHasUpdate( + LocalModelFile model, + CivitModel remoteModel, + string[] installedHashes + ) + { + var method = typeof(ModelIndexService).GetMethod( + "ComputeHasUpdate", + BindingFlags.NonPublic | BindingFlags.Static + ); + + Assert.IsNotNull(method); + + var result = method.Invoke(null, [model, remoteModel, new HashSet(installedHashes)]); + + Assert.IsNotNull(result); + + return (bool)result; + } + + private static CivitFile CreateFile(CivitFileType type, string? blake3) + { + return new CivitFile + { + Name = "file.safetensors", + Type = type, + Hashes = new CivitFileHashes { BLAKE3 = blake3 }, + }; + } + private static bool InvokeGetHasEarlyAccessUpdateOnly(LocalModelFile model, CivitModel? remoteModel) { var method = typeof(ModelIndexService).GetMethod( @@ -99,7 +333,11 @@ private static bool InvokeGetHasEarlyAccessUpdateOnly(LocalModelFile model, Civi return (bool)result; } - private static LocalModelFile CreateLocalModel(int installedVersionId, bool hasUpdate) + private static LocalModelFile CreateLocalModel( + int installedVersionId, + bool hasUpdate, + string? baseModel = null + ) { return new LocalModelFile { @@ -114,6 +352,7 @@ private static LocalModelFile CreateLocalModel(int installedVersionId, bool hasU ModelName = "Test Model", ModelDescription = string.Empty, VersionName = $"v{installedVersionId}", + BaseModel = baseModel, Tags = [], Hashes = new CivitFileHashes(), }, @@ -134,7 +373,17 @@ private static CivitModel CreateRemoteModel(params CivitModelVersion[] versions) }; } - private static CivitModelVersion CreateVersion(int id, bool isEarlyAccess) + private static CivitModelVersion CreateVersion(int id, bool isEarlyAccess, params CivitFile[] files) + { + return CreateVersion(id, isEarlyAccess, baseModel: null, files); + } + + private static CivitModelVersion CreateVersion( + int id, + bool isEarlyAccess, + string? baseModel, + params CivitFile[] files + ) { return new CivitModelVersion { @@ -144,7 +393,9 @@ private static CivitModelVersion CreateVersion(int id, bool isEarlyAccess) DownloadUrl = string.Empty, TrainedWords = [], Availability = isEarlyAccess ? "EarlyAccess" : "Public", + BaseModel = baseModel, Stats = new CivitModelStats(), + Files = files.Length > 0 ? files.ToList() : null, }; } } From 09d53f6ef7fc448da817957735409ce4949deafc Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 16 Aug 2026 12:25:47 -0700 Subject: [PATCH 08/27] Replace OpenArt browser with CivitAI workflow browsing + ComfyUI linking OpenArt has discontinued its ComfyUI workflow community: the site no longer has workflow pages, the public API feed has had a single upload since March, and the unmoderated "latest" feed surfaced spam listings at the top of our browser tab. Rather than filter a dead catalog, replace the source: - Remove the OpenArt browser tab, API client, and import dialog. The OpenArt-shaped metadata models stay - every previously imported workflow embeds that schema, and it now serves as the general on-disk metadata format (WorkflowMetadata, with a new sm_source_url field). - Model Browser gains the CivitAI "Workflows" model type. Importing a workflow downloads the archive, extracts the workflow jsons into the Workflows library, and embeds library metadata in each file (DownloadCivitWorkflowStep). Model-only surfaces (install locations, bulk download, cm-info sidecars) are bypassed for workflow models so sidecar files can't appear as phantom entries in the library. - ComfyUI packages now link the shared Workflows library into user/default/workflows/"Stability Matrix", so imported workflows appear natively in ComfyUI's own workflow browser (applies to ComfyUI-Zluda via inheritance; skipped when shared folders are disabled). - The Workflows page drops its tab bar and is now the installed workflow library directly; cards for CivitAI imports link back to their source page. Legacy OpenArt imports keep working, minus the now-dead site link. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + StabilityMatrix.Avalonia/App.axaml.cs | 11 +- .../DesignData/DesignData.cs | 56 +-- .../Helpers/EnumHelpers.cs | 2 +- .../Models/InstalledWorkflow.cs | 19 + .../Models/OpenArtCustomNode.cs | 10 - .../Models/OpenArtMetadata.cs | 27 -- .../CivitAiBrowserViewModel.cs | 11 +- .../CivitDetailsPageViewModel.cs | 81 +++- .../Dialogs/OpenArtWorkflowViewModel.cs | 189 --------- .../ViewModels/InstalledWorkflowsViewModel.cs | 58 +-- .../ViewModels/OpenArtBrowserViewModel.cs | 355 ----------------- .../ViewModels/WorkflowsPageViewModel.cs | 40 -- .../Views/CivitDetailsPage.axaml | 1 + .../Views/Dialogs/OpenArtWorkflowDialog.axaml | 137 ------- .../Dialogs/OpenArtWorkflowDialog.axaml.cs | 13 - .../Views/InstalledWorkflowsPage.axaml | 11 +- .../Views/OpenArtBrowserPage.axaml | 362 ------------------ .../Views/OpenArtBrowserPage.axaml.cs | 41 -- .../Views/WorkflowsPage.axaml | 12 - .../Views/WorkflowsPage.axaml.cs | 13 - StabilityMatrix.Core/Api/IOpenArtApi.cs | 17 - .../Models/Api/CivitModelType.cs | 13 +- .../Models/Api/OpenArt/OpenArtDateTime.cs | 14 - .../Api/OpenArt/OpenArtDownloadRequest.cs | 15 - .../Api/OpenArt/OpenArtDownloadResponse.cs | 12 - .../Models/Api/OpenArt/OpenArtFeedRequest.cs | 21 - .../Api/OpenArt/OpenArtSearchRequest.cs | 18 - .../Api/OpenArt/OpenArtSearchResponse.cs | 15 - .../DownloadCivitWorkflowStep.cs | 196 ++++++++++ .../DownloadOpenArtWorkflowStep.cs | 36 -- .../Models/Packages/ComfyUI.cs | 40 ++ .../Models/WorkflowMetadata.cs | 33 ++ .../Core/DownloadCivitWorkflowStepTests.cs | 190 +++++++++ .../Packages/ComfyUIWorkflowLinkTests.cs | 104 +++++ 35 files changed, 721 insertions(+), 1458 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs delete mode 100644 StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs delete mode 100644 StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs delete mode 100644 StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs delete mode 100644 StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs delete mode 100644 StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs delete mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml delete mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs delete mode 100644 StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml delete mode 100644 StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs delete mode 100644 StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml delete mode 100644 StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml.cs delete mode 100644 StabilityMatrix.Core/Api/IOpenArtApi.cs delete mode 100644 StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDateTime.cs delete mode 100644 StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadRequest.cs delete mode 100644 StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadResponse.cs delete mode 100644 StabilityMatrix.Core/Models/Api/OpenArt/OpenArtFeedRequest.cs delete mode 100644 StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchRequest.cs delete mode 100644 StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchResponse.cs create mode 100644 StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs delete mode 100644 StabilityMatrix.Core/Models/PackageModification/DownloadOpenArtWorkflowStep.cs create mode 100644 StabilityMatrix.Core/Models/WorkflowMetadata.cs create mode 100644 StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs create mode 100644 StabilityMatrix.Tests/Models/Packages/ComfyUIWorkflowLinkTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a73d709..d8915490d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,13 @@ 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.16.3 +### Added +#### New Feature: 🔀 CivitAI Workflow Browsing +- The **Model Browser** now browses and imports **ComfyUI workflows** from CivitAI — pick the new **Workflows** model type (or spot them in All-type searches), open one, and import it straight into your workflow library; archives are unpacked automatically +- Your workflow library is now **linked into ComfyUI itself** — imported workflows appear under a "Stability Matrix" folder in ComfyUI's own workflow browser, for every installed ComfyUI-based package ### Changed +- The **Workflows** page is now your installed workflow library, front and center — no more tabs, since browsing moved to the Model Browser. Cards for CivitAI-imported workflows link back to their CivitAI page +- Removed the **OpenArt workflow browser**: OpenArt has discontinued its ComfyUI workflow community (the site no longer has workflow pages, nothing new has been uploaded since March, and the unmoderated feed had filled with spam). Workflows you already imported from OpenArt stay fully usable in your library - Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) — with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs - Updated the bundled **uv** package manager to 0.12.5, adding newer Python builds to the Python picker — including the 3.10 security releases requested in [#1709](https://github.com/LykosAI/StabilityMatrix/issues/1709) ### Fixed diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 3dde9483c..07a196cf2 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -469,7 +469,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services) provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), }, FooterPages = { provider.GetRequiredService() }, }); @@ -836,15 +836,6 @@ internal static IServiceCollection ConfigureServices(bool disableMessagePipeInte .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) .AddPolicyHandler(retryPolicy); - services - .AddRefitClient(defaultRefitSettings) - .ConfigureHttpClient(c => - { - c.BaseAddress = new Uri("https://openart.ai/api/public/workflows"); - c.Timeout = TimeSpan.FromHours(1); - }) - .AddPolicyHandler(retryPolicy); - services .AddRefitClient(defaultRefitSettings) // Assuming defaultRefitSettings is suitable .ConfigureHttpClient(c => diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 2c4322057..8d8972ec3 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -1295,7 +1295,7 @@ public static InstalledWorkflowsViewModel InstalledWorkflowsViewModel get { var vm = Services.GetRequiredService(); - vm.DisplayedWorkflows = new ObservableCollectionExtended + vm.DisplayedWorkflows = new ObservableCollectionExtended { new() { @@ -1602,60 +1602,6 @@ public static CompletionList SampleCompletionList public static SponsorshipPromptViewModel SponsorshipPromptViewModel => DialogFactory.Get(vm => { }); - public static OpenArtWorkflowViewModel OpenArtWorkflowViewModel => - new(Services.GetRequiredService(), Services.GetRequiredService()) - { - Workflow = new OpenArtSearchResult - { - Name = "Test Workflow", - Creator = new OpenArtCreator - { - Name = "Test Creator Name", - Username = "Test Creator Username", - }, - Thumbnails = - [ - new OpenArtThumbnail - { - Url = new Uri( - "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1500" - ), - }, - ], - NodesIndex = - [ - "Anything Everywhere", - "Reroute", - "Note", - ".", - "ComfyUI's ControlNet Auxiliary Preprocessors", - "DWPreprocessor", - "PixelPerfectResolution", - "AIO_Preprocessor", - ",", - "ComfyUI", - "PreviewImage", - "CLIPTextEncode", - "EmptyLatentImage", - "SplitImageWithAlpha", - "ControlNetApplyAdvanced", - "JoinImageWithAlpha", - "LatentUpscaleBy", - "VAEEncode", - "LoadImage", - "ControlNetLoader", - "CLIPVisionLoader", - "SaveImage", - ",", - "ComfyUI Impact Pack", - "SAMLoader", - "UltralyticsDetectorProvider", - "FaceDetailer", - ",", - ], - }, - }; - public static SafetensorMetadataViewModel SafetensorMetadataViewModel => DialogFactory.Get(vm => { diff --git a/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs b/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs index 9d037ed45..2d53d6ae3 100644 --- a/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs +++ b/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs @@ -18,7 +18,7 @@ public static class EnumHelpers public static IEnumerable AllCivitModelTypes { get; } = Enum.GetValues(typeof(CivitModelType)) .Cast() - .Where(t => t == CivitModelType.All || t.ConvertTo() > 0) + .Where(t => t == CivitModelType.All || t.IsBrowsable()) .OrderBy(t => t.ToString()); public static IEnumerable MetadataEditorCivitModelTypes { get; } = diff --git a/StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs b/StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs new file mode 100644 index 000000000..a60ef5dd0 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Avalonia.Platform.Storage; +using StabilityMatrix.Core.Models; + +namespace StabilityMatrix.Avalonia.Models; + +/// +/// A workflow file in the installed workflows library, deserialized from the +/// embedded in the file. +/// +public class InstalledWorkflow : WorkflowMetadata +{ + [JsonIgnore] + public List? FilePath { get; set; } + + [JsonIgnore] + internal int Index { get; set; } +} diff --git a/StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs b/StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs deleted file mode 100644 index b075758dc..000000000 --- a/StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace StabilityMatrix.Avalonia.Models; - -public class OpenArtCustomNode -{ - public required string Title { get; set; } - public List Children { get; set; } = []; - public bool IsInstalled { get; set; } -} diff --git a/StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs b/StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs deleted file mode 100644 index ef7506a18..000000000 --- a/StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json.Serialization; -using Avalonia.Platform.Storage; -using StabilityMatrix.Core.Models.Api.OpenArt; - -namespace StabilityMatrix.Avalonia.Models; - -public class OpenArtMetadata -{ - [JsonPropertyName("sm_workflow_data")] - public OpenArtSearchResult? Workflow { get; set; } - - [JsonIgnore] - public string? FirstThumbnail => Workflow?.Thumbnails?.Select(x => x.Url).FirstOrDefault()?.ToString(); - - [JsonIgnore] - public List? FilePath { get; set; } - - [JsonIgnore] - [MemberNotNullWhen(true, nameof(Workflow))] - public bool HasMetadata => Workflow?.Creator != null; - - [JsonIgnore] - internal int Index { get; set; } -} diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs index 0a6854e05..a9218efca 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs @@ -131,7 +131,7 @@ public sealed partial class CivitAiBrowserViewModel : TabViewModelBase, IInfinit public IEnumerable AllModelTypes => Enum.GetValues(typeof(CivitModelType)) .Cast() - .Where(t => t == CivitModelType.All || t.ConvertTo() > 0) + .Where(t => t == CivitModelType.All || t.IsBrowsable()) .OrderBy(t => t.ToString()); public string ClearButtonText => @@ -519,9 +519,7 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc } // Check how many items survive local filtering - var filteredCount = models - .Where(m => m.Type.ConvertTo() > 0) - .Count(m => m.Mode == null); + var filteredCount = models.Where(m => m.Type.IsBrowsable()).Count(m => m.Mode == null); var next = resp.Metadata?.NextCursor; if (filteredCount >= targetCount || string.IsNullOrEmpty(next)) @@ -564,10 +562,7 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc } // Filter out unknown model types and archived/taken-down models - models = models - .Where(m => m.Type.ConvertTo() > 0) - .Where(m => m.Mode == null) - .ToList(); + models = models.Where(m => m.Type.IsBrowsable()).Where(m => m.Mode == null).ToList(); var cacheNew = true; if (UseLocalCache) diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs index 0d25ef06e..9a42b7df8 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs @@ -34,6 +34,7 @@ using StabilityMatrix.Core.Models.Api.CivitTRPC; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Services; @@ -51,13 +52,24 @@ public partial class CivitDetailsPageViewModel( INavigationService navigationService, IModelIndexService modelIndexService, IServiceManager vmFactory, - IModelImportService modelImportService + IModelImportService modelImportService, + IDownloadService downloadService ) : DisposableViewModelBase { [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowInferenceDefaultsSection), nameof(CivitUrl))] + [NotifyPropertyChangedFor( + nameof(ShowInferenceDefaultsSection), + nameof(CivitUrl), + nameof(IsWorkflowModel) + )] public required partial CivitModel CivitModel { get; set; } + /// + /// Workflow models import into the workflow library rather than the models directory, + /// and hide model-only surfaces such as bulk download. + /// + public bool IsWorkflowModel => CivitModel.Type is CivitModelType.Workflows; + [ObservableProperty] public required partial List ModelIdList { get; set; } @@ -397,6 +409,12 @@ protected override async Task OnInitialLoadedAsync() public async Task DownloadModelAsync(CivitFileViewModel viewModel, string? locationKey = null) { + if (IsWorkflowModel) + { + await DownloadWorkflowAsync(viewModel); + return; + } + DirectoryPath? finalDestinationDir = null; var effectiveLocationKeyForPreference = string.Empty; @@ -559,6 +577,56 @@ await modelImportService.DoImport( } } + /// + /// Imports a workflow file into the workflow library: downloads the archive, extracts the + /// contained workflow jsons, and embeds library metadata in each. + /// + private async Task DownloadWorkflowAsync(CivitFileViewModel viewModel) + { + if (SelectedVersion?.ModelVersion is not { } modelVersion) + { + notificationService.Show( + new Notification( + "No version selected", + "Select a version to import this workflow from", + NotificationType.Warning + ) + ); + return; + } + + var runner = new PackageModificationRunner + { + ShowDialogOnStart = true, + ModificationCompleteTitle = Resources.Label_WorkflowImported, + ModificationCompleteMessage = Resources.Label_FinishedImportingWorkflow, + }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + await runner.ExecuteSteps( + [ + new DownloadCivitWorkflowStep( + CivitModel, + modelVersion, + viewModel.CivitFile, + downloadService, + settingsManager + ), + ] + ); + + if (runner.Failed) + return; + + notificationService.Show( + Resources.Label_WorkflowImported, + Resources.Label_WorkflowImportComplete, + NotificationType.Success + ); + + EventManager.Instance.OnWorkflowInstalled(); + } + [RelayCommand] private async Task ShowBulkDownloadDialogAsync() { @@ -857,6 +925,10 @@ private void VmOnNavigateToModelRequested(object? sender, int modelId) private bool ShouldIncludeCivitFile(CivitFile file) { + // Workflow models publish their payload as archive/workflow files, not model weights + if (IsWorkflowModel) + return true; + if (ShowTrainingData) return true; @@ -923,6 +995,11 @@ private ObservableCollection LoadInstallLocations(CivitFile selectedFile if (Design.IsDesignMode) return ["Models/StableDiffusion", "Custom..."]; + if (IsWorkflowModel) + { + return [Path.GetFileName(settingsManager.WorkflowDirectory)]; + } + var installLocations = new List(); var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory); diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs deleted file mode 100644 index a1848afe8..000000000 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using AsyncAwaitBestPractices; -using Avalonia.Controls; -using CommunityToolkit.Mvvm.ComponentModel; -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.Views.Dialogs; -using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.Helper.Factory; -using StabilityMatrix.Core.Models; -using StabilityMatrix.Core.Models.Api.OpenArt; -using StabilityMatrix.Core.Models.Packages.Extensions; -using StabilityMatrix.Core.Services; - -namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; - -[View(typeof(OpenArtWorkflowDialog))] -[ManagedService] -[RegisterTransient] -public partial class OpenArtWorkflowViewModel( - ISettingsManager settingsManager, - IPackageFactory packageFactory -) : ContentDialogViewModelBase -{ - public required OpenArtSearchResult Workflow { get; init; } - - [ObservableProperty] - private ObservableCollection customNodes = []; - - [ObservableProperty] - private string prunedDescription = string.Empty; - - [ObservableProperty] - private bool installRequiredNodes = true; - - [ObservableProperty] - private InstalledPackage? selectedPackage; - - public PackagePair? SelectedPackagePair => - SelectedPackage is { } package ? packageFactory.GetPackagePair(package) : null; - - public List AvailablePackages => - settingsManager - .Settings.InstalledPackages.Where(package => package.PackageName is "ComfyUI" or "ComfyUI-Zluda") - .ToList(); - - public List MissingNodes { get; } = []; - - public override async Task OnLoadedAsync() - { - if (Design.IsDesignMode) - return; - - if (settingsManager.Settings.PreferredWorkflowPackage is { } preferredPackage) - { - SelectedPackage = preferredPackage; - } - else - { - SelectedPackage = AvailablePackages.FirstOrDefault(); - } - - if (SelectedPackage == null) - { - InstallRequiredNodes = false; - } - - CustomNodes = new ObservableCollection( - await ParseNodes(Workflow.NodesIndex.ToList()) - ); - PrunedDescription = Utilities.RemoveHtml(Workflow.Description); - } - - partial void OnSelectedPackageChanged(InstalledPackage? oldValue, InstalledPackage? newValue) - { - if (oldValue is null) - return; - - settingsManager.Transaction(settings => - { - settings.PreferredWorkflowPackage = newValue; - }); - - OnLoadedAsync().SafeFireAndForget(); - } - - [Localizable(false)] - private async Task> ParseNodes(List nodes) - { - var indexOfFirstDot = nodes.IndexOf("."); - if (indexOfFirstDot != -1) - { - nodes = nodes[(indexOfFirstDot + 1)..]; - } - - var installedNodesNames = new HashSet(); - var nameToManifestNodes = new Dictionary(); - var addedMissingNodes = new HashSet(); - - var packagePair = SelectedPackagePair; - - if (packagePair?.BasePackage.ExtensionManager is { } extensionManager) - { - var installedNodes = ( - await extensionManager.GetInstalledExtensionsLiteAsync(packagePair.InstalledPackage) - ).ToList(); - - var manifestExtensionsMap = await extensionManager.GetManifestExtensionsMapAsync( - extensionManager.GetManifests(packagePair.InstalledPackage) - ); - - // Add manifestExtensions definition to installedNodes if matching git repository url - installedNodes = installedNodes - .Select(installedNode => - { - if ( - installedNode.GitRepositoryUrl is not null - && manifestExtensionsMap.TryGetValue( - installedNode.GitRepositoryUrl, - out var manifestExtension - ) - ) - { - installedNode = installedNode with { Definition = manifestExtension }; - } - - return installedNode; - }) - .ToList(); - - // There may be duplicate titles, deduplicate by using the first one - nameToManifestNodes = manifestExtensionsMap - .GroupBy(x => x.Value.Title) - .ToDictionary(x => x.Key, x => x.First().Value); - - installedNodesNames = installedNodes.Select(x => x.Title).ToHashSet(); - } - - var sections = new List(); - OpenArtCustomNode? currentSection = null; - - foreach (var node in nodes) - { - if (node is "." or ",") - { - currentSection = null; // End of the current section - continue; - } - - if (currentSection == null) - { - currentSection = new OpenArtCustomNode - { - Title = node, - IsInstalled = installedNodesNames.Contains(node), - }; - - // Add missing nodes to the list (deduplicate by title) - if ( - !currentSection.IsInstalled - && addedMissingNodes.Add(node) - && nameToManifestNodes.TryGetValue(node, out var manifestNode) - ) - { - MissingNodes.Add(manifestNode); - } - - sections.Add(currentSection); - } - else - { - currentSection.Children.Add(node); - } - } - - if (sections.FirstOrDefault(x => x.Title == "ComfyUI") != null) - { - sections = sections.Where(x => x.Title != "ComfyUI").ToList(); - } - - return sections; - } -} diff --git a/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs index 606b75923..97edd6be9 100644 --- a/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs @@ -1,4 +1,4 @@ -using System.Reactive.Linq; +using System.Reactive.Linq; using System.Text.Json; using AsyncAwaitBestPractices; using Avalonia.Controls; @@ -28,16 +28,18 @@ namespace StabilityMatrix.Avalonia.ViewModels; public partial class InstalledWorkflowsViewModel( ISettingsManager settingsManager, INotificationService notificationService -) : TabViewModelBase +) : PageViewModelBase { - public override string Header => Resources.TabLabel_InstalledWorkflows; + public override string Title => Resources.Label_Workflows; + public override IconSource IconSource => new FASymbolIconSource { Symbol = "fa-solid fa-circle-nodes" }; - private readonly SourceCache workflowsCache = - new(x => x.Workflow?.Id ?? Guid.NewGuid().ToString()); + private readonly SourceCache workflowsCache = new(x => + x.Workflow?.Id ?? Guid.NewGuid().ToString() + ); [ObservableProperty] - private IObservableCollection displayedWorkflows = - new ObservableCollectionExtended(); + private IObservableCollection displayedWorkflows = + new ObservableCollectionExtended(); [ObservableProperty] private string searchQuery = string.Empty; @@ -49,7 +51,7 @@ protected override async Task OnInitialLoadedAsync() var searchPredicate = this.WhenPropertyChanged(vm => vm.SearchQuery) .Throttle(TimeSpan.FromMilliseconds(100)) .DistinctUntilChanged() - .Select(_ => (Func)FilterWorkflows); + .Select(_ => (Func)FilterWorkflows); AddDisposable( workflowsCache @@ -92,21 +94,21 @@ var workflowPath in Directory.EnumerateFiles( try { var json = await File.ReadAllTextAsync(workflowPath); - var metadata = JsonSerializer.Deserialize(json); + var metadata = JsonSerializer.Deserialize(json); if (metadata?.Workflow == null) { - metadata = new OpenArtMetadata + metadata = new InstalledWorkflow { Workflow = new OpenArtSearchResult { Id = Guid.NewGuid().ToString(), Name = Path.GetFileNameWithoutExtension(workflowPath), }, - Index = count++, }; } + metadata.Index = count++; metadata.FilePath = [await App.StorageProvider.TryGetFileFromPathAsync(workflowPath)]; workflowsCache.AddOrUpdate(metadata); } @@ -118,12 +120,12 @@ var workflowPath in Directory.EnumerateFiles( } [RelayCommand] - private async Task OpenInExplorer(OpenArtMetadata metadata) + private async Task OpenInExplorer(InstalledWorkflow workflow) { - if (metadata.FilePath == null) + if (workflow.FilePath == null) return; - var path = metadata.FilePath.FirstOrDefault()?.Path.ToString(); + var path = workflow.FilePath.FirstOrDefault()?.Path.ToString(); if (string.IsNullOrWhiteSpace(path)) return; @@ -131,16 +133,16 @@ private async Task OpenInExplorer(OpenArtMetadata metadata) } [RelayCommand] - private void OpenOnOpenArt(OpenArtMetadata metadata) + private void OpenSourcePage(InstalledWorkflow workflow) { - if (metadata.Workflow == null) - return; - - ProcessRunner.OpenUrl($"https://openart.ai/workflows/{metadata.Workflow.Id}"); + if (workflow.SourceUrl is { } url && !string.IsNullOrWhiteSpace(url)) + { + ProcessRunner.OpenUrl(url); + } } [RelayCommand] - private async Task DeleteAsync(OpenArtMetadata metadata) + private async Task DeleteAsync(InstalledWorkflow workflow) { var confirmationDialog = new BetterContentDialog { @@ -157,7 +159,7 @@ private async Task DeleteAsync(OpenArtMetadata metadata) await using var delay = new MinimumDelay(200, 500); - var path = metadata?.FilePath?.FirstOrDefault()?.Path.ToString().Replace("file:///", ""); + var path = workflow?.FilePath?.FirstOrDefault()?.Path.ToString().Replace("file:///", ""); if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { await notificationService.TryAsync( @@ -165,7 +167,7 @@ await notificationService.TryAsync( message: "Error deleting workflow" ); - var id = metadata?.Workflow?.Id; + var id = workflow?.Workflow?.Id; if (!string.IsNullOrWhiteSpace(id)) { workflowsCache.Remove(id); @@ -174,22 +176,22 @@ await notificationService.TryAsync( notificationService.Show( Resources.Label_WorkflowDeleted, - string.Format(Resources.Label_WorkflowDeletedSuccessfully, metadata?.Workflow?.Name) + string.Format(Resources.Label_WorkflowDeletedSuccessfully, workflow?.Workflow?.Name) ); } - private bool FilterWorkflows(OpenArtMetadata metadata) + private bool FilterWorkflows(InstalledWorkflow workflow) { if (string.IsNullOrWhiteSpace(SearchQuery)) return true; - if (metadata.HasMetadata) + if (workflow.HasMetadata) { - return metadata.Workflow.Creator.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) - || metadata.Workflow.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase); + return workflow.Workflow.Creator.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) + || workflow.Workflow.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase); } - return metadata.Workflow?.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false; + return workflow.Workflow?.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false; } private void OnWorkflowInstalled(object? sender, EventArgs e) diff --git a/StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs deleted file mode 100644 index 7c52302e9..000000000 --- a/StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using AsyncAwaitBestPractices; -using Avalonia.Controls.Notifications; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using DynamicData; -using DynamicData.Binding; -using FluentAvalonia.UI.Controls; -using Injectio.Attributes; -using Refit; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Models; -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.Helper; -using StabilityMatrix.Core.Helper.Factory; -using StabilityMatrix.Core.Models.Api.OpenArt; -using StabilityMatrix.Core.Models.PackageModification; -using StabilityMatrix.Core.Processes; -using StabilityMatrix.Core.Services; -using Resources = StabilityMatrix.Avalonia.Languages.Resources; - -namespace StabilityMatrix.Avalonia.ViewModels; - -[View(typeof(OpenArtBrowserPage))] -[RegisterSingleton] -public partial class OpenArtBrowserViewModel( - IOpenArtApi openArtApi, - INotificationService notificationService, - ISettingsManager settingsManager, - IPackageFactory packageFactory -) : TabViewModelBase, IInfinitelyScroll -{ - private const int PageSize = 20; - - public override string Header => Resources.Label_OpenArtBrowser; - - private readonly SourceCache searchResultsCache = new(x => x.Id); - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PageCount), nameof(CanGoBack), nameof(CanGoForward), nameof(CanGoToEnd))] - private OpenArtSearchResponse? latestSearchResponse; - - [ObservableProperty] - private IObservableCollection searchResults = - new ObservableCollectionExtended(); - - [ObservableProperty] - private string searchQuery = string.Empty; - - [ObservableProperty] - private bool isLoading; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(InternalPageNumber), nameof(CanGoBack))] - private int displayedPageNumber = 1; - - public int InternalPageNumber => DisplayedPageNumber - 1; - - public int PageCount => - Math.Max( - 1, - Convert.ToInt32(Math.Ceiling((LatestSearchResponse?.Total ?? 0) / Convert.ToDouble(PageSize))) - ); - - public bool CanGoBack => - string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor) && InternalPageNumber > 0; - - public bool CanGoForward => - !string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor) || PageCount > InternalPageNumber + 1; - - public bool CanGoToEnd => - string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor) && PageCount > InternalPageNumber + 1; - - public IEnumerable AllSortModes => ["Trending", "Latest", "Most Downloaded", "Most Liked"]; - - [ObservableProperty] - private string? selectedSortMode; - - protected override void OnInitialLoaded() - { - searchResultsCache - .Connect() - .DeferUntilLoaded() - .Bind(SearchResults) - .ObserveOn(SynchronizationContext.Current) - .Subscribe(); - SelectedSortMode = AllSortModes.First(); - DoSearch().SafeFireAndForget(); - } - - [RelayCommand] - private async Task FirstPage() - { - DisplayedPageNumber = 1; - searchResultsCache.Clear(); - - await DoSearch(); - } - - [RelayCommand] - private async Task PreviousPage() - { - DisplayedPageNumber--; - searchResultsCache.Clear(); - - await DoSearch(InternalPageNumber); - } - - [RelayCommand] - private async Task NextPage() - { - if (string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - DisplayedPageNumber++; - } - - searchResultsCache.Clear(); - await DoSearch(InternalPageNumber); - } - - [RelayCommand] - private async Task LastPage() - { - if (string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - DisplayedPageNumber = PageCount; - } - - searchResultsCache.Clear(); - await DoSearch(PageCount - 1); - } - - [Localizable(false)] - [RelayCommand] - private void OpenModel(OpenArtSearchResult workflow) - { - ProcessRunner.OpenUrl($"https://openart.ai/workflows/{workflow.Id}"); - } - - [RelayCommand] - private async Task SearchButton() - { - DisplayedPageNumber = 1; - LatestSearchResponse = null; - searchResultsCache.Clear(); - - await DoSearch(); - } - - [RelayCommand] - private async Task OpenWorkflow(OpenArtSearchResult workflow) - { - var vm = new OpenArtWorkflowViewModel(settingsManager, packageFactory) { Workflow = workflow }; - - var dialog = new BetterContentDialog - { - IsPrimaryButtonEnabled = true, - IsSecondaryButtonEnabled = true, - PrimaryButtonText = Resources.Action_Import, - SecondaryButtonText = Resources.Action_Cancel, - DefaultButton = ContentDialogButton.Primary, - IsFooterVisible = true, - MaxDialogWidth = 750, - MaxDialogHeight = 850, - CloseOnClickOutside = true, - Content = vm - }; - - var result = await dialog.ShowAsync(); - - if (result != ContentDialogResult.Primary) - return; - - List steps = - [ - new DownloadOpenArtWorkflowStep(openArtApi, vm.Workflow, settingsManager) - ]; - - // Add install steps if missing nodes and preferred - if ( - vm is - { - InstallRequiredNodes: true, - MissingNodes: { Count: > 0 } missingNodes, - SelectedPackage: not null, - SelectedPackagePair: not null - } - ) - { - var extensionManager = vm.SelectedPackagePair.BasePackage.ExtensionManager!; - - steps.AddRange( - missingNodes.Select( - extension => - new InstallExtensionStep( - extensionManager, - vm.SelectedPackagePair.InstalledPackage, - extension - ) - ) - ); - } - - var runner = new PackageModificationRunner - { - ShowDialogOnStart = true, - ModificationCompleteTitle = Resources.Label_WorkflowImported, - ModificationCompleteMessage = Resources.Label_FinishedImportingWorkflow - }; - EventManager.Instance.OnPackageInstallProgressAdded(runner); - - await runner.ExecuteSteps(steps); - - notificationService.Show( - Resources.Label_WorkflowImported, - Resources.Label_WorkflowImportComplete, - NotificationType.Success - ); - - EventManager.Instance.OnWorkflowInstalled(); - } - - [RelayCommand] - private void OpenOnOpenArt(OpenArtSearchResult? workflow) - { - if (workflow?.Id == null) - return; - - ProcessRunner.OpenUrl($"https://openart.ai/workflows/{workflow.Id}"); - } - - private async Task DoSearch(int page = 0) - { - IsLoading = true; - - try - { - OpenArtSearchResponse? response = null; - if (string.IsNullOrWhiteSpace(SearchQuery)) - { - var request = new OpenArtFeedRequest { Sort = GetSortMode(SelectedSortMode) }; - if (!string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - request.Cursor = LatestSearchResponse.NextCursor; - } - - response = await openArtApi.GetFeedAsync(request); - } - else - { - response = await openArtApi.SearchAsync( - new OpenArtSearchRequest - { - Keyword = SearchQuery, - PageSize = PageSize, - CurrentPage = page - } - ); - } - - foreach (var item in response.Items) - { - searchResultsCache.AddOrUpdate(item); - } - - LatestSearchResponse = response; - } - catch (ApiException e) - { - notificationService.Show(Resources.Label_ErrorRetrievingWorkflows, e.Message); - } - finally - { - IsLoading = false; - } - } - - partial void OnSelectedSortModeChanged(string? value) - { - if (value is null || SearchResults.Count == 0) - return; - - searchResultsCache.Clear(); - LatestSearchResponse = null; - - DoSearch().SafeFireAndForget(); - } - - public async Task LoadNextPageAsync() - { - if (!CanGoForward) - return; - - try - { - OpenArtSearchResponse? response = null; - if (string.IsNullOrWhiteSpace(SearchQuery)) - { - var request = new OpenArtFeedRequest { Sort = GetSortMode(SelectedSortMode) }; - if (!string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - request.Cursor = LatestSearchResponse.NextCursor; - } - - response = await openArtApi.GetFeedAsync(request); - } - else - { - DisplayedPageNumber++; - response = await openArtApi.SearchAsync( - new OpenArtSearchRequest - { - Keyword = SearchQuery, - PageSize = PageSize, - CurrentPage = InternalPageNumber - } - ); - } - - foreach (var item in response.Items) - { - searchResultsCache.AddOrUpdate(item); - } - - LatestSearchResponse = response; - } - catch (ApiException e) - { - notificationService.Show("Unable to load the next page", e.Message); - } - } - - private static string GetSortMode(string? sortMode) - { - return sortMode switch - { - "Trending" => "trending", - "Latest" => "latest", - "Most Downloaded" => "most_downloaded", - "Most Liked" => "most_liked", - _ => "trending" - }; - } -} diff --git a/StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs deleted file mode 100644 index fe6c8b642..000000000 --- a/StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Avalonia.Controls; -using CommunityToolkit.Mvvm.ComponentModel; -using FluentAvalonia.UI.Controls; -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Languages; -using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.Views; -using StabilityMatrix.Core.Attributes; - -namespace StabilityMatrix.Avalonia.ViewModels; - -[View(typeof(WorkflowsPage))] -[RegisterSingleton] -public partial class WorkflowsPageViewModel : PageViewModelBase -{ - public override string Title => Resources.Label_Workflows; - public override IconSource IconSource => new FASymbolIconSource { Symbol = "fa-solid fa-circle-nodes" }; - - public IReadOnlyList Pages { get; } - - [ObservableProperty] - private TabItem? selectedPage; - - /// - public WorkflowsPageViewModel( - OpenArtBrowserViewModel openArtBrowserViewModel, - InstalledWorkflowsViewModel installedWorkflowsViewModel - ) - { - Pages = new List( - new List([openArtBrowserViewModel, installedWorkflowsViewModel]).Select( - vm => new TabItem { Header = vm.Header, Content = vm } - ) - ); - SelectedPage = Pages.FirstOrDefault(); - } -} diff --git a/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml b/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml index 239e5d2db..bb25fb707 100644 --- a/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml @@ -215,6 +215,7 @@ diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml deleted file mode 100644 index a5950c331..000000000 --- a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs b/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs deleted file mode 100644 index d9fe00a75..000000000 --- a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; - -namespace StabilityMatrix.Avalonia.Views.Dialogs; - -[RegisterTransient] -public partial class OpenArtWorkflowDialog : UserControlBase -{ - public OpenArtWorkflowDialog() - { - InitializeComponent(); - } -} diff --git a/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml b/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml index e7ef3fa28..81757225e 100644 --- a/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml @@ -15,7 +15,6 @@ xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" - xmlns:openArt="clr-namespace:StabilityMatrix.Core.Models.Api.OpenArt" xmlns:vendorLabs="clr-namespace:StabilityMatrix.Avalonia.Controls.VendorLabs" xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" d:DataContext="{x:Static designData:DesignData.InstalledWorkflowsViewModel}" @@ -71,7 +70,7 @@ - + @@ -128,7 +127,7 @@ - + + Header="{x:Static lang:Resources.Action_OpenOnCivitAi}" + IsVisible="{Binding SourceUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> diff --git a/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml b/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml deleted file mode 100644 index 35582b939..000000000 --- a/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml +++ /dev/null @@ -1,362 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs b/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs deleted file mode 100644 index 002ece52c..000000000 --- a/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using AsyncAwaitBestPractices; -using Avalonia.Controls; -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Core.Services; - -namespace StabilityMatrix.Avalonia.Views; - -[RegisterSingleton] -public partial class OpenArtBrowserPage : UserControlBase -{ - private readonly ISettingsManager settingsManager; - - public OpenArtBrowserPage(ISettingsManager settingsManager) - { - this.settingsManager = settingsManager; - InitializeComponent(); - } - - private void ScrollViewer_OnScrollChanged(object? sender, ScrollChangedEventArgs e) - { - if (sender is not ScrollViewer scrollViewer) - return; - - if (scrollViewer.Offset.Y == 0) - return; - - var isAtEnd = Math.Abs(scrollViewer.Offset.Y - scrollViewer.ScrollBarMaximum.Y) < 1f; - - if ( - isAtEnd - && settingsManager.Settings.IsWorkflowInfiniteScrollEnabled - && DataContext is IInfinitelyScroll scroll - ) - { - scroll.LoadNextPageAsync().SafeFireAndForget(); - } - } -} diff --git a/StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml b/StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml deleted file mode 100644 index 71d4982d1..000000000 --- a/StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml.cs deleted file mode 100644 index e0bce5180..000000000 --- a/StabilityMatrix.Avalonia/Views/WorkflowsPage.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; - -namespace StabilityMatrix.Avalonia.Views; - -[RegisterSingleton] -public partial class WorkflowsPage : UserControlBase -{ - public WorkflowsPage() - { - InitializeComponent(); - } -} diff --git a/StabilityMatrix.Core/Api/IOpenArtApi.cs b/StabilityMatrix.Core/Api/IOpenArtApi.cs deleted file mode 100644 index f7e9ae199..000000000 --- a/StabilityMatrix.Core/Api/IOpenArtApi.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Refit; -using StabilityMatrix.Core.Models.Api.OpenArt; - -namespace StabilityMatrix.Core.Api; - -[Headers("User-Agent: StabilityMatrix")] -public interface IOpenArtApi -{ - [Get("/feed")] - Task GetFeedAsync([Query] OpenArtFeedRequest request); - - [Get("/list")] - Task SearchAsync([Query] OpenArtSearchRequest request); - - [Post("/download")] - Task DownloadWorkflowAsync([Body] OpenArtDownloadRequest request); -} diff --git a/StabilityMatrix.Core/Models/Api/CivitModelType.cs b/StabilityMatrix.Core/Models/Api/CivitModelType.cs index 1bbf160cc..f7e2f5fe0 100644 --- a/StabilityMatrix.Core/Models/Api/CivitModelType.cs +++ b/StabilityMatrix.Core/Models/Api/CivitModelType.cs @@ -47,5 +47,16 @@ public enum CivitModelType Wildcards, Workflows, Other, - All + All, +} + +public static class CivitModelTypeExtensions +{ + /// + /// True for model types the model browser can list and import: types with a shared-folder + /// destination, plus , which imports into the + /// workflow library instead of the models directory. + /// + public static bool IsBrowsable(this CivitModelType type) => + type is CivitModelType.Workflows || type.ConvertTo() > 0; } diff --git a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDateTime.cs b/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDateTime.cs deleted file mode 100644 index 5f45a2327..000000000 --- a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDateTime.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace StabilityMatrix.Core.Models.Api.OpenArt; - -public class OpenArtDateTime -{ - [JsonPropertyName("_seconds")] - public long Seconds { get; set; } - - public DateTimeOffset ToDateTimeOffset() - { - return DateTimeOffset.FromUnixTimeSeconds(Seconds); - } -} diff --git a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadRequest.cs b/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadRequest.cs deleted file mode 100644 index cdf07d27d..000000000 --- a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadRequest.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; -using Refit; - -namespace StabilityMatrix.Core.Models.Api.OpenArt; - -public class OpenArtDownloadRequest -{ - [AliasAs("workflow_id")] - [JsonPropertyName("workflow_id")] - public required string WorkflowId { get; set; } - - [AliasAs("version_tag")] - [JsonPropertyName("version_tag")] - public string VersionTag { get; set; } = "latest"; -} diff --git a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadResponse.cs b/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadResponse.cs deleted file mode 100644 index 3cb61d798..000000000 --- a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtDownloadResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace StabilityMatrix.Core.Models.Api.OpenArt; - -public class OpenArtDownloadResponse -{ - [JsonPropertyName("filename")] - public string Filename { get; set; } - - [JsonPropertyName("payload")] - public string Payload { get; set; } -} diff --git a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtFeedRequest.cs b/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtFeedRequest.cs deleted file mode 100644 index f8dd52559..000000000 --- a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtFeedRequest.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Refit; - -namespace StabilityMatrix.Core.Models.Api.OpenArt; - -/// -/// Note that parameters Category, Custom Node and Sort should be used separately -/// -public class OpenArtFeedRequest -{ - [AliasAs("category")] - public string Category { get; set; } - - [AliasAs("sort")] - public string Sort { get; set; } - - [AliasAs("custom_node")] - public string CustomNode { get; set; } - - [AliasAs("cursor")] - public string Cursor { get; set; } -} diff --git a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchRequest.cs b/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchRequest.cs deleted file mode 100644 index 27d944e35..000000000 --- a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Refit; - -namespace StabilityMatrix.Core.Models.Api.OpenArt; - -public class OpenArtSearchRequest -{ - [AliasAs("keyword")] - public required string Keyword { get; set; } - - [AliasAs("pageSize")] - public int PageSize { get; set; } = 30; - - /// - /// 0-based index of the page to retrieve - /// - [AliasAs("currentPage")] - public int CurrentPage { get; set; } = 0; -} diff --git a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchResponse.cs b/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchResponse.cs deleted file mode 100644 index c58e308ac..000000000 --- a/StabilityMatrix.Core/Models/Api/OpenArt/OpenArtSearchResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace StabilityMatrix.Core.Models.Api.OpenArt; - -public class OpenArtSearchResponse -{ - [JsonPropertyName("items")] - public IEnumerable Items { get; set; } - - [JsonPropertyName("total")] - public int Total { get; set; } - - [JsonPropertyName("nextCursor")] - public string? NextCursor { get; set; } -} diff --git a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs new file mode 100644 index 000000000..e16a2d3d5 --- /dev/null +++ b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs @@ -0,0 +1,196 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Nodes; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.Api.OpenArt; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Core.Models.PackageModification; + +/// +/// Downloads a CivitAI workflow file (a zip of ComfyUI workflow jsons, or a bare json) and +/// imports the contained workflows into the shared workflow library, embedding +/// in each file for the installed workflows page. +/// +public class DownloadCivitWorkflowStep( + CivitModel model, + CivitModelVersion version, + CivitFile file, + IDownloadService downloadService, + ISettingsManager settingsManager +) : IPackageStep +{ + public string ProgressTitle => "Downloading Workflow"; + + public async Task ExecuteAsync(IProgress? progress = null) + { + var tempFile = new FilePath( + Path.GetTempPath(), + $"sm-workflow-{Guid.NewGuid():N}{Path.GetExtension(file.Name)}" + ); + + try + { + await downloadService + .DownloadToFileAsync(file.GetFileSpecificDownloadUrl(), tempFile, progress) + .ConfigureAwait(false); + + var targetDir = settingsManager.WorkflowDirectory.JoinDir(SanitizeFileName(model.Name)); + targetDir.Create(); + + var importedCount = + tempFile.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase) + ? await ExtractArchiveAsync(tempFile, targetDir).ConfigureAwait(false) + : await ImportWorkflowJsonAsync( + await File.ReadAllTextAsync(tempFile).ConfigureAwait(false), + Path.GetFileNameWithoutExtension(file.Name), + targetDir, + isMultiple: false + ) + .ConfigureAwait(false) + ? 1 + : 0; + + if (importedCount == 0) + { + throw new InvalidOperationException( + $"No ComfyUI workflow json found in \"{file.Name}\" - " + + "the file may not contain importable workflows" + ); + } + + progress?.Report(new ProgressReport(1f, $"Imported {importedCount} workflow(s)")); + } + finally + { + if (tempFile.Exists) + { + tempFile.Delete(); + } + } + } + + private async Task ExtractArchiveAsync(FilePath archivePath, DirectoryPath targetDir) + { + using var archive = ZipFile.OpenRead(archivePath); + + var entries = archive + .Entries.Where(entry => + !string.IsNullOrEmpty(entry.Name) + && entry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !entry.FullName.Contains("__MACOSX", StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + + var importedCount = 0; + foreach (var entry in entries) + { + using var reader = new StreamReader(entry.Open()); + var json = await reader.ReadToEndAsync().ConfigureAwait(false); + + if ( + await ImportWorkflowJsonAsync( + json, + Path.GetFileNameWithoutExtension(entry.Name), + targetDir, + isMultiple: entries.Count > 1 + ) + .ConfigureAwait(false) + ) + { + importedCount++; + } + } + + return importedCount; + } + + /// + /// Writes a single workflow json into the target directory with metadata embedded. + /// Returns false when the content is not a json object and was skipped. + /// + private async Task ImportWorkflowJsonAsync( + string json, + string name, + DirectoryPath targetDir, + bool isMultiple + ) + { + JsonObject? workflow; + try + { + workflow = JsonNode.Parse(json) as JsonObject; + } + catch (JsonException) + { + return false; + } + + if (workflow is null) + return false; + + var metadata = JsonSerializer.SerializeToNode(CreateMetadata(name, isMultiple))!.AsObject(); + foreach (var (key, value) in metadata.ToList()) + { + metadata.Remove(key); + workflow[key] = value; + } + + var filePath = targetDir.JoinFile($"{SanitizeFileName(name)}.json"); + await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(workflow)).ConfigureAwait(false); + + return true; + } + + private WorkflowMetadata CreateMetadata(string name, bool isMultiple) + { + var thumbnail = (version.Images ?? model.ModelVersions?.FirstOrDefault()?.Images)?.FirstOrDefault( + image => image.Type == "image" + ); + + return new WorkflowMetadata + { + SourceUrl = $"https://civitai.com/models/{model.Id}?modelVersionId={version.Id}", + Workflow = new OpenArtSearchResult + { + // Unique per json so the installed workflows cache never collides + Id = $"civitai-{model.Id}-{version.Id}-{name}", + Name = isMultiple ? $"{model.Name} ({name})" : model.Name, + Creator = model.Creator is { } creator + ? new OpenArtCreator + { + Name = creator.Username ?? string.Empty, + Username = creator.Username ?? string.Empty, + Avatar = Uri.TryCreate(creator.Image, UriKind.Absolute, out var avatar) + ? avatar + : null, + DevProfileUrl = creator.ProfileUrl ?? string.Empty, + } + : null, + Stats = new OpenArtStats + { + NumDownloads = model.Stats?.DownloadCount ?? 0, + NumLikes = model.Stats?.ThumbsUpCount ?? 0, + NumReviews = model.Stats?.RatingCount ?? 0, + Rating = model.Stats?.Rating ?? 0, + }, + Thumbnails = thumbnail is null + ? [] + : + [ + new OpenArtThumbnail + { + Url = new Uri(thumbnail.Url), + Width = thumbnail.Width, + Height = thumbnail.Height, + }, + ], + }, + }; + } + + private static string SanitizeFileName(string name) => + Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')).Trim(); +} diff --git a/StabilityMatrix.Core/Models/PackageModification/DownloadOpenArtWorkflowStep.cs b/StabilityMatrix.Core/Models/PackageModification/DownloadOpenArtWorkflowStep.cs deleted file mode 100644 index 1b682c4ae..000000000 --- a/StabilityMatrix.Core/Models/PackageModification/DownloadOpenArtWorkflowStep.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using StabilityMatrix.Core.Api; -using StabilityMatrix.Core.Models.Api.OpenArt; -using StabilityMatrix.Core.Models.Progress; -using StabilityMatrix.Core.Services; - -namespace StabilityMatrix.Core.Models.PackageModification; - -public class DownloadOpenArtWorkflowStep( - IOpenArtApi openArtApi, - OpenArtSearchResult workflow, - ISettingsManager settingsManager -) : IPackageStep -{ - public async Task ExecuteAsync(IProgress? progress = null) - { - var workflowData = await openArtApi - .DownloadWorkflowAsync(new OpenArtDownloadRequest { WorkflowId = workflow.Id }) - .ConfigureAwait(false); - - var workflowJson = JsonSerializer.SerializeToNode(workflow); - - Directory.CreateDirectory(settingsManager.WorkflowDirectory); - var filePath = Path.Combine(settingsManager.WorkflowDirectory, $"{workflowData.Filename}.json"); - - var jsonObject = JsonNode.Parse(workflowData.Payload) as JsonObject; - jsonObject?.Add("sm_workflow_data", workflowJson); - - await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(jsonObject)).ConfigureAwait(false); - - progress?.Report(new ProgressReport(1f, "Downloaded OpenArt Workflow")); - } - - public string ProgressTitle => "Downloading OpenArt Workflow"; -} diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index a59792e44..7f32fc9d8 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -226,6 +226,46 @@ IRocmPackageHelper rocmPackageHelper public override Dictionary>? SharedOutputFolders => new() { [SharedOutputType.Text2Img] = ["output"] }; + public override async Task SetupModelFolders( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) + { + await base.SetupModelFolders(installDirectory, sharedFolderMethod).ConfigureAwait(false); + + if (sharedFolderMethod is SharedFolderMethod.None) + return; + + // Link the shared workflow library into ComfyUI's own workflow browser + var linkDir = GetSharedWorkflowsLinkDir(installDirectory); + linkDir.Parent?.Create(); + + await Helper + .SharedFolders.CreateOrUpdateLink(settingsManager.WorkflowDirectory, linkDir) + .ConfigureAwait(false); + } + + public override async Task RemoveModelFolderLinks( + DirectoryPath installDirectory, + SharedFolderMethod sharedFolderMethod + ) + { + await base.RemoveModelFolderLinks(installDirectory, sharedFolderMethod).ConfigureAwait(false); + + var linkDir = GetSharedWorkflowsLinkDir(installDirectory); + if (linkDir.IsSymbolicLink) + { + await linkDir.DeleteAsync(false).ConfigureAwait(false); + } + } + + /// + /// The "Stability Matrix" folder inside ComfyUI's default user workflows directory, + /// linked to the shared workflow library. + /// + private static DirectoryPath GetSharedWorkflowsLinkDir(DirectoryPath installDirectory) => + installDirectory.JoinDir("user", "default", "workflows", "Stability Matrix"); + public override List LaunchOptions => [ new() diff --git a/StabilityMatrix.Core/Models/WorkflowMetadata.cs b/StabilityMatrix.Core/Models/WorkflowMetadata.cs new file mode 100644 index 000000000..0481f8d41 --- /dev/null +++ b/StabilityMatrix.Core/Models/WorkflowMetadata.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using StabilityMatrix.Core.Models.Api.OpenArt; + +namespace StabilityMatrix.Core.Models; + +/// +/// Stability Matrix metadata embedded at the root of imported ComfyUI workflow files. +/// ComfyUI ignores unknown root-level keys, so the file stays loadable as a plain workflow. +/// +public class WorkflowMetadata +{ + /// + /// Display data for the installed workflows library. The shape is the OpenArt + /// search-result schema that historical imports embedded; other sources map into it. + /// + [JsonPropertyName("sm_workflow_data")] + public OpenArtSearchResult? Workflow { get; set; } + + /// + /// Web page this workflow was imported from, when the source site still has one. + /// Null for legacy OpenArt imports, whose workflow pages no longer exist. + /// + [JsonPropertyName("sm_source_url")] + public string? SourceUrl { get; set; } + + [JsonIgnore] + public string? FirstThumbnail => Workflow?.Thumbnails?.Select(x => x.Url).FirstOrDefault()?.ToString(); + + [JsonIgnore] + [MemberNotNullWhen(true, nameof(Workflow))] + public bool HasMetadata => Workflow?.Creator != null; +} diff --git a/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs b/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs new file mode 100644 index 000000000..bdf376ca1 --- /dev/null +++ b/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs @@ -0,0 +1,190 @@ +using System.IO.Compression; +using System.Text.Json.Nodes; +using NSubstitute; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class DownloadCivitWorkflowStepTests +{ + private const string WorkflowJson = """{"nodes": [], "links": []}"""; + + private string tempDir = null!; + private ISettingsManager settingsManager = null!; + private IDownloadService downloadService = null!; + + private static CivitModel TestModel => + new() + { + Id = 123, + Name = "Test Workflow Pack", + Type = CivitModelType.Workflows, + Creator = new CivitCreator { Username = "tester" }, + Stats = new CivitModelStats { DownloadCount = 5, ThumbsUpCount = 2 }, + }; + + private static CivitModelVersion TestVersion => + new() + { + Id = 456, + Name = "v1.0", + Images = + [ + new CivitImage + { + Url = "https://example.com/preview.jpg", + Type = "image", + Width = 512, + Height = 512, + }, + ], + }; + + [TestInitialize] + public void Initialize() + { + tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + settingsManager = Substitute.For(); + settingsManager.WorkflowDirectory.Returns(new DirectoryPath(tempDir, "Workflows")); + + downloadService = Substitute.For(); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + + [TestMethod] + public async Task ImportsWorkflowJsonsFromZip_WithMetadataEmbedded() + { + SetupDownload( + BuildZip( + ("workflow-a.json", WorkflowJson), + ("nested/workflow-b.json", WorkflowJson), + ("preview.png", "not json"), + ("__MACOSX/workflow-a.json", WorkflowJson) + ) + ); + + await CreateStep(fileName: "pack.zip").ExecuteAsync(); + + var importedFiles = Directory + .EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories) + .OrderBy(f => f) + .ToList(); + + Assert.AreEqual(2, importedFiles.Count); + Assert.IsTrue(importedFiles.Any(f => f.EndsWith("workflow-a.json"))); + Assert.IsTrue(importedFiles.Any(f => f.EndsWith("workflow-b.json"))); + StringAssert.Contains(importedFiles[0], "Test Workflow Pack"); + + var metadataIds = new HashSet(); + foreach (var file in importedFiles) + { + var root = JsonNode.Parse(await File.ReadAllTextAsync(file))!.AsObject(); + + // Original workflow content is preserved + Assert.IsNotNull(root["nodes"]); + + var workflowData = root["sm_workflow_data"]!.AsObject(); + StringAssert.Contains(workflowData["name"]!.GetValue(), "Test Workflow Pack"); + Assert.AreEqual("tester", workflowData["creator"]!["username"]!.GetValue()); + Assert.AreEqual(5, workflowData["stats"]!["num_downloads"]!.GetValue()); + Assert.AreEqual( + "https://example.com/preview.jpg", + workflowData["thumbnails"]![0]!["url"]!.GetValue() + ); + Assert.AreEqual( + "https://civitai.com/models/123?modelVersionId=456", + root["sm_source_url"]!.GetValue() + ); + + metadataIds.Add(workflowData["id"]!.GetValue()); + } + + // Each file gets a unique library id so the installed workflows cache never collides + Assert.AreEqual(2, metadataIds.Count); + } + + [TestMethod] + public async Task ImportsBareWorkflowJson() + { + SetupDownload(System.Text.Encoding.UTF8.GetBytes(WorkflowJson)); + + await CreateStep(fileName: "single-workflow.json").ExecuteAsync(); + + var importedFile = Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories).Single(); + + var root = JsonNode.Parse(await File.ReadAllTextAsync(importedFile))!.AsObject(); + Assert.AreEqual("Test Workflow Pack", root["sm_workflow_data"]!["name"]!.GetValue()); + } + + [TestMethod] + public async Task ThrowsWhenArchiveContainsNoWorkflows() + { + SetupDownload(BuildZip(("readme.txt", "no workflows here"))); + + await Assert.ThrowsExceptionAsync(() => + CreateStep(fileName: "empty.zip").ExecuteAsync() + ); + } + + private DownloadCivitWorkflowStep CreateStep(string fileName) => + new( + TestModel, + TestVersion, + new CivitFile + { + Id = 789, + Name = fileName, + DownloadUrl = "https://civitai.com/api/download/models/456", + }, + downloadService, + settingsManager + ); + + private void SetupDownload(byte[] content) + { + downloadService + .DownloadToFileAsync( + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any() + ) + .Returns(callInfo => + { + File.WriteAllBytes(callInfo.ArgAt(1), content); + return Task.CompletedTask; + }); + } + + private static byte[] BuildZip(params (string Name, string Content)[] entries) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + } + + return stream.ToArray(); + } +} diff --git a/StabilityMatrix.Tests/Models/Packages/ComfyUIWorkflowLinkTests.cs b/StabilityMatrix.Tests/Models/Packages/ComfyUIWorkflowLinkTests.cs new file mode 100644 index 000000000..0f2f91572 --- /dev/null +++ b/StabilityMatrix.Tests/Models/Packages/ComfyUIWorkflowLinkTests.cs @@ -0,0 +1,104 @@ +using NSubstitute; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using StabilityMatrix.Core.Services.Rocm; + +namespace StabilityMatrix.Tests.Models.Packages; + +[TestClass] +public class ComfyUIWorkflowLinkTests +{ + private string tempDir = null!; + private DirectoryPath workflowsDir = null!; + private DirectoryPath installDir = null!; + private ComfyUI comfy = null!; + + [TestInitialize] + public void Initialize() + { + tempDir = Path.Combine(Path.GetTempPath(), $"sm-test-{Guid.NewGuid():N}"); + workflowsDir = new DirectoryPath(tempDir, "Workflows"); + installDir = new DirectoryPath(tempDir, "Packages", "ComfyUI"); + installDir.Create(); + + var settingsManager = Substitute.For(); + settingsManager.WorkflowDirectory.Returns(workflowsDir); + settingsManager.ModelsDirectory.Returns(Path.Combine(tempDir, "Models")); + + comfy = new ComfyUI( + Substitute.For(), + settingsManager, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + } + + [TestCleanup] + public void Cleanup() + { + if (!Directory.Exists(tempDir)) + return; + + // Junction links must be unlinked (non-recursively) before the recursive delete, + // which would otherwise fail or follow into the link target + var links = new DirectoryInfo(tempDir) + .EnumerateDirectories("*", SearchOption.AllDirectories) + .Where(d => d.Attributes.HasFlag(FileAttributes.ReparsePoint)) + .ToList(); + + foreach (var link in links) + { + link.Attributes = FileAttributes.Normal; + link.Delete(false); + } + + Directory.Delete(tempDir, true); + } + + [TestMethod] + public async Task SetupModelFolders_LinksWorkflowLibraryIntoComfyUserDir() + { + await comfy.SetupModelFolders(installDir, SharedFolderMethod.Configuration); + + var linkDir = installDir.JoinDir("user", "default", "workflows", "Stability Matrix"); + Assert.IsTrue(linkDir.Exists, "Workflow library link was not created"); + Assert.IsTrue(linkDir.IsSymbolicLink, "Workflow library link is not a link"); + + // Files written through the link land in the shared library + await File.WriteAllTextAsync(linkDir.JoinFile("test.json"), "{}"); + Assert.IsTrue(File.Exists(workflowsDir.JoinFile("test.json"))); + } + + [TestMethod] + public async Task SetupModelFolders_SkipsLinkWhenSharingDisabled() + { + await comfy.SetupModelFolders(installDir, SharedFolderMethod.None); + + Assert.IsFalse(installDir.JoinDir("user", "default", "workflows", "Stability Matrix").Exists); + } + + [TestMethod] + public async Task RemoveModelFolderLinks_RemovesLinkButKeepsLibrary() + { + await comfy.SetupModelFolders(installDir, SharedFolderMethod.Configuration); + + var linkDir = installDir.JoinDir("user", "default", "workflows", "Stability Matrix"); + await File.WriteAllTextAsync(linkDir.JoinFile("test.json"), "{}"); + + await comfy.RemoveModelFolderLinks(installDir, SharedFolderMethod.Configuration); + + Assert.IsFalse(linkDir.Exists, "Link should be removed"); + Assert.IsTrue( + File.Exists(workflowsDir.JoinFile("test.json")), + "Shared library content should survive link removal" + ); + } +} From 9e7ea1e4bb7df85057a4d1ddeb9b938681e8f116 Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 16 Aug 2026 18:42:07 -0700 Subject: [PATCH 09/27] Import workflows embedded in PNG images Some CivitAI workflow packs ship zips of PNGs rather than json files (e.g. WAN 2.2 Kijai Wrapper) - ComfyUI embeds the full workflow graph in a "workflow" tEXt chunk when saving images, which is what makes drag-into-ComfyUI work. The import step now extracts that chunk (via the existing ImageMetadata chunk reader) from .png archive entries and bare .png files, imports it as a regular workflow json - so the ComfyUI workflows-folder link keeps working - and keeps the image itself as the workflow's preview sidecar. Images without an embedded workflow are skipped; the no-workflows error message now covers both shapes. Chunk format verified against real ComfyUI output files (uncompressed tEXt, keyword "workflow"), matching what the metadata reader supports. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../DownloadCivitWorkflowStep.cs | 105 +++++++++++++++--- .../Core/DownloadCivitWorkflowStepTests.cs | 83 ++++++++++++++ 3 files changed, 173 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8915490d..fa1104790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.3 ### Added #### New Feature: 🔀 CivitAI Workflow Browsing -- The **Model Browser** now browses and imports **ComfyUI workflows** from CivitAI — pick the new **Workflows** model type (or spot them in All-type searches), open one, and import it straight into your workflow library; archives are unpacked automatically +- The **Model Browser** now browses and imports **ComfyUI workflows** from CivitAI — pick the new **Workflows** model type (or spot them in All-type searches), open one, and import it straight into your workflow library; archives are unpacked automatically, and images with an embedded workflow (the drag-into-ComfyUI kind) are converted into regular workflows with the image kept as the preview - Your workflow library is now **linked into ComfyUI itself** — imported workflows appear under a "Stability Matrix" folder in ComfyUI's own workflow browser, for every installed ComfyUI-based package ### Changed - The **Workflows** page is now your installed workflow library, front and center — no more tabs, since browsing moved to the Model Browser. Cards for CivitAI-imported workflows link back to their CivitAI page diff --git a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs index e16a2d3d5..baab5b8e4 100644 --- a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs @@ -1,6 +1,7 @@ using System.IO.Compression; using System.Text.Json; using System.Text.Json.Nodes; +using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.OpenArt; using StabilityMatrix.Core.Models.FileInterfaces; @@ -40,24 +41,36 @@ await downloadService var targetDir = settingsManager.WorkflowDirectory.JoinDir(SanitizeFileName(model.Name)); targetDir.Create(); - var importedCount = - tempFile.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase) - ? await ExtractArchiveAsync(tempFile, targetDir).ConfigureAwait(false) - : await ImportWorkflowJsonAsync( + var fileStem = Path.GetFileNameWithoutExtension(file.Name); + + var importedCount = tempFile.Extension.ToLowerInvariant() switch + { + ".zip" => await ExtractArchiveAsync(tempFile, targetDir).ConfigureAwait(false), + ".png" => await ImportWorkflowFromPngAsync( + await File.ReadAllBytesAsync(tempFile).ConfigureAwait(false), + fileStem, + targetDir, + isMultiple: false + ) + .ConfigureAwait(false) + ? 1 + : 0, + _ => await ImportWorkflowJsonAsync( await File.ReadAllTextAsync(tempFile).ConfigureAwait(false), - Path.GetFileNameWithoutExtension(file.Name), + fileStem, targetDir, isMultiple: false ) .ConfigureAwait(false) ? 1 - : 0; + : 0, + }; if (importedCount == 0) { throw new InvalidOperationException( - $"No ComfyUI workflow json found in \"{file.Name}\" - " - + "the file may not contain importable workflows" + $"No ComfyUI workflows found in \"{file.Name}\" - " + + "the file contains neither workflow json nor images with an embedded workflow" ); } @@ -79,26 +92,51 @@ private async Task ExtractArchiveAsync(FilePath archivePath, DirectoryPath var entries = archive .Entries.Where(entry => !string.IsNullOrEmpty(entry.Name) - && entry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && !entry.FullName.Contains("__MACOSX", StringComparison.OrdinalIgnoreCase) + && ( + entry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + || entry.Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + ) ) .ToList(); var importedCount = 0; foreach (var entry in entries) { - using var reader = new StreamReader(entry.Open()); - var json = await reader.ReadToEndAsync().ConfigureAwait(false); + var entryStem = Path.GetFileNameWithoutExtension(entry.Name); + bool imported; - if ( - await ImportWorkflowJsonAsync( + if (entry.Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + { + using var memoryStream = new MemoryStream(); + await using (var entryStream = entry.Open()) + { + await entryStream.CopyToAsync(memoryStream).ConfigureAwait(false); + } + + imported = await ImportWorkflowFromPngAsync( + memoryStream.ToArray(), + entryStem, + targetDir, + isMultiple: entries.Count > 1 + ) + .ConfigureAwait(false); + } + else + { + using var reader = new StreamReader(entry.Open()); + var json = await reader.ReadToEndAsync().ConfigureAwait(false); + + imported = await ImportWorkflowJsonAsync( json, - Path.GetFileNameWithoutExtension(entry.Name), + entryStem, targetDir, isMultiple: entries.Count > 1 ) - .ConfigureAwait(false) - ) + .ConfigureAwait(false); + } + + if (imported) { importedCount++; } @@ -107,6 +145,41 @@ await ImportWorkflowJsonAsync( return importedCount; } + /// + /// Imports a workflow from the "workflow" metadata chunk ComfyUI embeds in saved images + /// (the same data loaded when dragging the image into ComfyUI). The image itself is kept + /// as the imported workflow's preview. Returns false when no workflow is embedded. + /// + private async Task ImportWorkflowFromPngAsync( + byte[] pngBytes, + string name, + DirectoryPath targetDir, + bool isMultiple + ) + { + string workflowJson; + try + { + using var reader = new BinaryReader(new MemoryStream(pngBytes)); + workflowJson = ImageMetadata.ReadTextChunk(reader, "workflow"); + } + catch (Exception) + { + return false; + } + + if (string.IsNullOrEmpty(workflowJson)) + return false; + + if (!await ImportWorkflowJsonAsync(workflowJson, name, targetDir, isMultiple).ConfigureAwait(false)) + return false; + + await File.WriteAllBytesAsync(targetDir.JoinFile($"{SanitizeFileName(name)}.preview.png"), pngBytes) + .ConfigureAwait(false); + + return true; + } + /// /// Writes a single workflow json into the target directory with metadata embedded. /// Returns false when the content is not a json object and was skipped. diff --git a/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs b/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs index bdf376ca1..b8883a366 100644 --- a/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs +++ b/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs @@ -141,6 +141,42 @@ await Assert.ThrowsExceptionAsync(() => ); } + [TestMethod] + public async Task ImportsWorkflowEmbeddedInPng_KeepingImageAsPreview() + { + SetupDownloadBytes(BuildPngWithWorkflow(WorkflowJson)); + + await CreateStep(fileName: "workflow-image.png").ExecuteAsync(); + + var importedJson = Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories).Single(); + + var root = JsonNode.Parse(await File.ReadAllTextAsync(importedJson))!.AsObject(); + Assert.IsNotNull(root["nodes"]); + Assert.IsNotNull(root["sm_workflow_data"]); + + // The image is kept as the workflow's preview sidecar + var sidecar = Path.Combine(Path.GetDirectoryName(importedJson)!, "workflow-image.preview.png"); + Assert.IsTrue(File.Exists(sidecar)); + } + + [TestMethod] + public async Task ImportsPngsFromZip_SkippingImagesWithoutEmbeddedWorkflow() + { + SetupDownloadBytes( + BuildZipBytes( + ("with-workflow.png", BuildPngWithWorkflow(WorkflowJson)), + ("plain-image.png", BuildPngWithWorkflow(null)) + ) + ); + + await CreateStep(fileName: "pngs.zip").ExecuteAsync(); + + var importedFiles = Directory.EnumerateFiles(tempDir, "*.json", SearchOption.AllDirectories).ToList(); + + Assert.AreEqual(1, importedFiles.Count); + Assert.IsTrue(importedFiles[0].EndsWith("with-workflow.json")); + } + private DownloadCivitWorkflowStep CreateStep(string fileName) => new( TestModel, @@ -155,6 +191,53 @@ private DownloadCivitWorkflowStep CreateStep(string fileName) => settingsManager ); + private void SetupDownloadBytes(byte[] content) => SetupDownload(content); + + /// + /// Minimal PNG: signature + optional "workflow" tEXt chunk + IEND. Chunk CRCs are + /// zeroed - the metadata reader walks chunks without validating them. + /// + private static byte[] BuildPngWithWorkflow(string? workflowJson) + { + using var stream = new MemoryStream(); + stream.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + void WriteChunk(string type, byte[] data) + { + var length = BitConverter.GetBytes(data.Length); + Array.Reverse(length); // big-endian + stream.Write(length); + stream.Write(System.Text.Encoding.ASCII.GetBytes(type)); + stream.Write(data); + stream.Write(new byte[4]); // crc, unvalidated + } + + if (workflowJson is not null) + { + WriteChunk("tEXt", System.Text.Encoding.UTF8.GetBytes($"workflow\0{workflowJson}")); + } + + WriteChunk("IEND", []); + + return stream.ToArray(); + } + + private static byte[] BuildZipBytes(params (string Name, byte[] Content)[] entries) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + entryStream.Write(content); + } + } + + return stream.ToArray(); + } + private void SetupDownload(byte[] content) { downloadService From 1dc7cc11bb536ebc413b2e040a103bea44c7fa12 Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 16 Aug 2026 19:18:44 -0700 Subject: [PATCH 10/27] Bound workflow preview sizes Workflow screenshots are often full-graph captures (thousands of pixels, multiple MB), which made the installed workflows page decode heavyweight images per card: - Imported PNG previews and metadata-editor image picks are downscaled to fit 1024px on the longest side (ImageThumbnailHelper, SkiaSharp) before being written as the preview sidecar. Undecodable input passes through unchanged. - The CivitAI thumbnail url stored in imported metadata gets its width transform capped at 700 (CivitaiUrlHelper.CapImageWidth), matching what cards actually render; browser cards already capped via CivitImageWidthConverter. Co-Authored-By: Claude Fable 5 --- .../Helper/CivitaiUrlHelper.cs | 20 ++++++++ .../Helper/ImageThumbnailHelper.cs | 48 +++++++++++++++++++ .../DownloadCivitWorkflowStep.cs | 15 ++++-- .../Helper/ImageThumbnailHelperTests.cs | 43 +++++++++++++++++ 4 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 StabilityMatrix.Core/Helper/ImageThumbnailHelper.cs create mode 100644 StabilityMatrix.Tests/Helper/ImageThumbnailHelperTests.cs diff --git a/StabilityMatrix.Core/Helper/CivitaiUrlHelper.cs b/StabilityMatrix.Core/Helper/CivitaiUrlHelper.cs index c7ceb4282..cf09a9a67 100644 --- a/StabilityMatrix.Core/Helper/CivitaiUrlHelper.cs +++ b/StabilityMatrix.Core/Helper/CivitaiUrlHelper.cs @@ -11,6 +11,26 @@ public static string GetModelUrl(int modelId, bool isNsfw, int? modelVersionId = return modelVersionId is > 0 ? $"{baseUrl}?modelVersionId={modelVersionId}" : baseUrl; } + /// + /// Returns a CivitAI CDN image url with its width transform capped at + /// . Urls without a width transform, or from + /// other hosts, are returned unchanged. + /// + public static string CapImageWidth(string url, int maxWidth) + { + if (!url.Contains("image.civitai.com", StringComparison.OrdinalIgnoreCase)) + return url; + + return System.Text.RegularExpressions.Regex.Replace( + url, + @"width=(\d+)", + match => + int.TryParse(match.Groups[1].Value, out var width) && width > maxWidth + ? $"width={maxWidth}" + : match.Value + ); + } + public static bool TryParseModelId(string? url, out int modelId) { modelId = 0; diff --git a/StabilityMatrix.Core/Helper/ImageThumbnailHelper.cs b/StabilityMatrix.Core/Helper/ImageThumbnailHelper.cs new file mode 100644 index 000000000..9932f16c6 --- /dev/null +++ b/StabilityMatrix.Core/Helper/ImageThumbnailHelper.cs @@ -0,0 +1,48 @@ +using SkiaSharp; + +namespace StabilityMatrix.Core.Helper; + +public static class ImageThumbnailHelper +{ + /// + /// Downscales an image to fit within on its longest side, + /// re-encoded as PNG. Returns the original bytes when the image is already small enough + /// or cannot be decoded. + /// + public static byte[] CreateThumbnail(byte[] imageBytes, int maxDimension = 1024) + { + SKBitmap? bitmap; + try + { + bitmap = SKBitmap.Decode(imageBytes); + } + catch (Exception) + { + return imageBytes; + } + + if (bitmap is null) + return imageBytes; + + using var _ = bitmap; + + var scale = (double)maxDimension / Math.Max(bitmap.Width, bitmap.Height); + if (scale >= 1) + return imageBytes; + + var width = Math.Max(1, (int)Math.Round(bitmap.Width * scale)); + var height = Math.Max(1, (int)Math.Round(bitmap.Height * scale)); + + using var resized = bitmap.Resize( + new SKImageInfo(width, height), + new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear) + ); + if (resized is null) + return imageBytes; + + using var image = SKImage.FromBitmap(resized); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + + return encoded.ToArray(); + } +} diff --git a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs index baab5b8e4..89e79c93c 100644 --- a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs @@ -174,7 +174,11 @@ bool isMultiple if (!await ImportWorkflowJsonAsync(workflowJson, name, targetDir, isMultiple).ConfigureAwait(false)) return false; - await File.WriteAllBytesAsync(targetDir.JoinFile($"{SanitizeFileName(name)}.preview.png"), pngBytes) + // Workflow screenshots can be huge full-graph captures; store a bounded preview + await File.WriteAllBytesAsync( + targetDir.JoinFile($"{SanitizeFileName(name)}.preview.png"), + ImageThumbnailHelper.CreateThumbnail(pngBytes) + ) .ConfigureAwait(false); return true; @@ -223,6 +227,9 @@ private WorkflowMetadata CreateMetadata(string name, bool isMultiple) image => image.Type == "image" ); + // Full-size workflow screenshots are heavy; cards never need more than this + var thumbnailUrl = thumbnail is null ? null : CivitaiUrlHelper.CapImageWidth(thumbnail.Url, 700); + return new WorkflowMetadata { SourceUrl = $"https://civitai.com/models/{model.Id}?modelVersionId={version.Id}", @@ -249,14 +256,14 @@ private WorkflowMetadata CreateMetadata(string name, bool isMultiple) NumReviews = model.Stats?.RatingCount ?? 0, Rating = model.Stats?.Rating ?? 0, }, - Thumbnails = thumbnail is null + Thumbnails = thumbnailUrl is null ? [] : [ new OpenArtThumbnail { - Url = new Uri(thumbnail.Url), - Width = thumbnail.Width, + Url = new Uri(thumbnailUrl), + Width = thumbnail!.Width, Height = thumbnail.Height, }, ], diff --git a/StabilityMatrix.Tests/Helper/ImageThumbnailHelperTests.cs b/StabilityMatrix.Tests/Helper/ImageThumbnailHelperTests.cs new file mode 100644 index 000000000..c746f18ac --- /dev/null +++ b/StabilityMatrix.Tests/Helper/ImageThumbnailHelperTests.cs @@ -0,0 +1,43 @@ +using SkiaSharp; +using StabilityMatrix.Core.Helper; + +namespace StabilityMatrix.Tests.Helper; + +[TestClass] +public class ImageThumbnailHelperTests +{ + [TestMethod] + public void DownscalesLargeImages_PreservingAspectRatio() + { + var thumbnail = ImageThumbnailHelper.CreateThumbnail(EncodePng(4000, 2000), maxDimension: 1024); + + using var decoded = SKBitmap.Decode(thumbnail); + Assert.AreEqual(1024, decoded.Width); + Assert.AreEqual(512, decoded.Height); + } + + [TestMethod] + public void LeavesSmallImagesUnchanged() + { + var original = EncodePng(200, 100); + + Assert.AreSame(original, ImageThumbnailHelper.CreateThumbnail(original, maxDimension: 1024)); + } + + [TestMethod] + public void LeavesUndecodableInputUnchanged() + { + var garbage = new byte[] { 1, 2, 3, 4, 5 }; + + Assert.AreSame(garbage, ImageThumbnailHelper.CreateThumbnail(garbage)); + } + + private static byte[] EncodePng(int width, int height) + { + using var bitmap = new SKBitmap(width, height); + bitmap.Erase(SKColors.CornflowerBlue); + using var image = SKImage.FromBitmap(bitmap); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + return encoded.ToArray(); + } +} From ca24a08244ed05330169bfb792fb4acad9596de0 Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 16 Aug 2026 20:49:18 -0700 Subject: [PATCH 11/27] Backport extension pip constraint and Linux test natives fixes - install.py scripts that shell out to plain pip (e.g. installing the legacy cupy-wheel sdist) now get PIP_CONSTRAINT pointing at the venv's setuptools<82 build constraints, fixing pkg_resources build failures that UV_BUILD_CONSTRAINT alone doesn't cover. - The tests project uses SkiaSharp.NativeAssets.Linux.NoDependencies so the new thumbnail tests run on CI agents without fontconfig. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + Directory.Packages.props | 1 + StabilityMatrix.Core/Models/Packages/ComfyUI.cs | 4 ++++ StabilityMatrix.Tests/StabilityMatrix.Tests.csproj | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1104790..8468b4dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) — with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs - Updated the bundled **uv** package manager to 0.12.5, adding newer Python builds to the Python picker — including the 3.10 security releases requested in [#1709](https://github.com/LykosAI/StabilityMatrix/issues/1709) ### Fixed +- Fixed custom node installs failing on legacy source builds that import `pkg_resources` (e.g. `cupy-wheel`) — pip runs started by extension install scripts now get the same `setuptools<82` constraint that package installs already applied for uv - Fixed [#1708](https://github.com/LykosAI/StabilityMatrix/issues/1708) - OneTrainer on **Windows ROCm** crashing at the start of training with bitsandbytes 8-bit optimizers — the incompatible bitsandbytes build is no longer installed, and a launch notice explains why until OneTrainer supports the new version - thanks to @NeuralFault for the diagnosis! - Fixed [#1710](https://github.com/LykosAI/StabilityMatrix/issues/1710) - CivitAI downloads sometimes fetching a different file from the same model version (then failing hash verification) when several files share identical metadata — downloads now pin the exact file - Fixed [#1703](https://github.com/LykosAI/StabilityMatrix/issues/1703) - model cover/preview images not displaying when your **File Name Pattern** contains a `#` diff --git a/Directory.Packages.props b/Directory.Packages.props index 14459b259..923f4d288 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -118,6 +118,7 @@ + diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index 7f32fc9d8..ec9b594a0 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -971,6 +971,10 @@ await venvRunner constraintsAbsPath ); env = env.SetItem("UV_BUILD_CONSTRAINT", constraintsRelPath); + // install.py scripts commonly shell out to plain pip themselves + // (e.g. "pip install cupy-wheel"), which ignores UV_BUILD_CONSTRAINT - + // PIP_CONSTRAINT applies the same setuptools pin to those installs + env = env.SetItem("PIP_CONSTRAINT", constraintsRelPath); // set env vars for Impact Pack for Face Detailer env = env.SetItem("COMFYUI_PATH", installedPackage.FullPath!); diff --git a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj index 04b652ffd..4ed90dbf6 100644 --- a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj +++ b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj @@ -20,6 +20,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + From fb1503c60edf8481de9e7be3e33097cf25c1b9e4 Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 16 Aug 2026 20:56:15 -0700 Subject: [PATCH 12/27] Backport import-time custom node install assist The removed OpenArt browser offered node installs when importing a workflow; without this, 2.16.3 would lose that capability. Ports the standalone analyzer + dialog from the dev line: required packs are read from the workflow file's embedded cnr_id/aux_id node properties, matched against the extension index (registry id first, repo/title fallback), and missing installable packs get one-click installs after import. The dialog only auto-opens when something is actionable. The dev line's extras (Check Required Nodes context action, and the rest of the workflows QoL round) stay 2.17-only. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../Languages/Resources.Designer.cs | 54 +++ .../Languages/Resources.resx | 18 + .../CivitDetailsPageViewModel.cs | 33 +- .../Dialogs/WorkflowNodesDialogViewModel.cs | 325 ++++++++++++++++++ .../Views/Dialogs/WorkflowNodesDialog.axaml | 129 +++++++ .../Dialogs/WorkflowNodesDialog.axaml.cs | 13 + .../Helper/WorkflowNodeAnalyzer.cs | 144 ++++++++ .../DownloadCivitWorkflowStep.cs | 13 +- .../Extensions/ComfyExtensionManifest.cs | 26 +- .../Packages/Extensions/PackageExtension.cs | 6 + .../Core/WorkflowNodeAnalyzerTests.cs | 91 +++++ 12 files changed, 826 insertions(+), 27 deletions(-) create mode 100644 StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs create mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml create mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs create mode 100644 StabilityMatrix.Core/Helper/WorkflowNodeAnalyzer.cs create mode 100644 StabilityMatrix.Tests/Core/WorkflowNodeAnalyzerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8468b4dc8..f926716aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 #### New Feature: 🔀 CivitAI Workflow Browsing - The **Model Browser** now browses and imports **ComfyUI workflows** from CivitAI — pick the new **Workflows** model type (or spot them in All-type searches), open one, and import it straight into your workflow library; archives are unpacked automatically, and images with an embedded workflow (the drag-into-ComfyUI kind) are converted into regular workflows with the image kept as the preview - Your workflow library is now **linked into ComfyUI itself** — imported workflows appear under a "Stability Matrix" folder in ComfyUI's own workflow browser, for every installed ComfyUI-based package +- **Custom node install assist**: after importing a workflow that needs custom nodes, Stability Matrix reads the node packs right out of the workflow file, shows which are already installed, and offers one-click installs for the rest ### Changed - The **Workflows** page is now your installed workflow library, front and center — no more tabs, since browsing moved to the Model Browser. Cards for CivitAI-imported workflows link back to their CivitAI page - Removed the **OpenArt workflow browser**: OpenArt has discontinued its ComfyUI workflow community (the site no longer has workflow pages, nothing new has been uploaded since March, and the unmoderated feed had filled with spam). Workflows you already imported from OpenArt stay fully usable in your library diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 867b9d276..b11dbd975 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -2858,6 +2858,60 @@ public static string Label_NodeDetails { return ResourceManager.GetString("Label_NodeDetails", resourceCulture); } } + + /// + /// Looks up a localized string similar to Required Custom Nodes. + /// + public static string Label_RequiredCustomNodes { + get { + return ResourceManager.GetString("Label_RequiredCustomNodes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not found in extension index. + /// + public static string Label_NotInExtensionIndex { + get { + return ResourceManager.GetString("Label_NotInExtensionIndex", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install Selected. + /// + public static string Action_InstallSelected { + get { + return ResourceManager.GetString("Action_InstallSelected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This workflow only uses built-in nodes. + /// + public static string Label_NoCustomNodesRequired { + get { + return ResourceManager.GetString("Label_NoCustomNodesRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This workflow uses the node packs below.. + /// + public static string Text_WorkflowNodesExplanation { + get { + return ResourceManager.GetString("Text_WorkflowNodesExplanation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ComfyUI installation to check against and install into. + /// + public static string Label_WorkflowNodesTargetPackage { + get { + return ResourceManager.GetString("Label_WorkflowNodesTargetPackage", resourceCulture); + } + } /// /// Looks up a localized string similar to No Extension Packs Found. diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index a9257a69a..8b3915911 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1038,6 +1038,24 @@ Node Details + + Required Custom Nodes + + + Not found in extension index + + + Install Selected + + + This workflow only uses built-in nodes + + + This workflow uses the node packs below. Missing packs can be installed from the extension index, like extensions from the package's Extensions tab. + + + ComfyUI installation to check against and install into + Workflow Description diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs index 9a42b7df8..1637791a3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs @@ -29,6 +29,7 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.CivitTRPC; @@ -53,7 +54,8 @@ public partial class CivitDetailsPageViewModel( IModelIndexService modelIndexService, IServiceManager vmFactory, IModelImportService modelImportService, - IDownloadService downloadService + IDownloadService downloadService, + IPackageFactory packageFactory ) : DisposableViewModelBase { [ObservableProperty] @@ -603,18 +605,16 @@ private async Task DownloadWorkflowAsync(CivitFileViewModel viewModel) }; EventManager.Instance.OnPackageInstallProgressAdded(runner); - await runner.ExecuteSteps( - [ - new DownloadCivitWorkflowStep( - CivitModel, - modelVersion, - viewModel.CivitFile, - downloadService, - settingsManager - ), - ] + var downloadStep = new DownloadCivitWorkflowStep( + CivitModel, + modelVersion, + viewModel.CivitFile, + downloadService, + settingsManager ); + await runner.ExecuteSteps([downloadStep]); + if (runner.Failed) return; @@ -625,6 +625,17 @@ await runner.ExecuteSteps( ); EventManager.Instance.OnWorkflowInstalled(); + + // Offer custom node installs when the imported workflows need any the user can act on + if (await WorkflowNodesDialogViewModel.HasRequiredPacksAsync(downloadStep.ImportedFiles)) + { + await WorkflowNodesDialogViewModel.ShowDialogAsync( + settingsManager, + packageFactory, + downloadStep.ImportedFiles, + onlyWhenActionable: true + ); + } } [RelayCommand] diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs new file mode 100644 index 000000000..6e1401cdd --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs @@ -0,0 +1,325 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using FluentAvalonia.UI.Controls; +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Factory; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +/// +/// Shows the custom node packs a set of workflow files requires, with install +/// options for packs that are missing but resolvable via the extension index. +/// +[View(typeof(WorkflowNodesDialog))] +[ManagedService] +[RegisterTransient] +public partial class WorkflowNodesDialogViewModel( + ISettingsManager settingsManager, + IPackageFactory packageFactory +) : ContentDialogViewModelBase +{ + public required IReadOnlyList WorkflowFiles { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNoPacks))] + public partial ObservableCollection Packs { get; set; } = []; + + [ObservableProperty] + public partial InstalledPackage? SelectedPackage { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNoPacks))] + public partial bool IsLoading { get; set; } + + public List AvailablePackages => + settingsManager + .Settings.InstalledPackages.Where(package => package.PackageName is "ComfyUI" or "ComfyUI-Zluda") + .ToList(); + + public PackagePair? SelectedPackagePair => + SelectedPackage is { } package ? packageFactory.GetPackagePair(package) : null; + + /// + /// True when analysis finished and the workflows need no custom nodes at all. + /// + public bool HasNoPacks => !IsLoading && Packs.Count == 0; + + /// + /// Parses the given workflow files and returns whether any of them reference + /// custom node packs, without any UI. Used to skip the dialog after imports + /// of workflows that only use built-in nodes. + /// + public static async Task HasRequiredPacksAsync(IEnumerable workflowFiles) + { + foreach (var file in workflowFiles.Where(f => f.Exists)) + { + try + { + if ( + JsonNode.Parse(await File.ReadAllTextAsync(file)) is JsonObject workflow + && WorkflowNodeAnalyzer.GetRequiredPacks(workflow).Count > 0 + ) + { + return true; + } + } + catch (Exception) + { + // Unparseable files can't tell us anything + } + } + + return false; + } + + private bool isInitialized; + + public override async Task OnLoadedAsync() + { + if (Design.IsDesignMode) + return; + + await EnsureInitializedAsync(); + } + + /// + /// Runs the pack analysis if it hasn't run yet. Callers may use this before showing + /// the dialog to decide whether showing it is worthwhile. + /// + public async Task EnsureInitializedAsync() + { + if (isInitialized) + return; + + isInitialized = true; + + SelectedPackage = + settingsManager.Settings.PreferredWorkflowPackage ?? AvailablePackages.FirstOrDefault(); + + await AnalyzeAsync(); + } + + partial void OnSelectedPackageChanged(InstalledPackage? oldValue, InstalledPackage? newValue) + { + if (oldValue is null) + return; + + settingsManager.Transaction(settings => + { + settings.PreferredWorkflowPackage = newValue; + }); + + AnalyzeAsync().SafeFireAndForget(); + } + + /// + /// The install steps for the packs the user selected, for the caller to run. + /// + public List GetInstallSteps() + { + if (SelectedPackagePair is not { BasePackage.ExtensionManager: { } extensionManager } pair) + return []; + + return Packs + .Where(pack => pack is { IsSelected: true, Extension: not null }) + .Select(pack => + (IPackageStep) + new InstallExtensionStep(extensionManager, pair.InstalledPackage, pack.Extension!) + ) + .ToList(); + } + + private async Task AnalyzeAsync() + { + IsLoading = true; + + try + { + var requiredPacks = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in WorkflowFiles.Where(f => f.Exists)) + { + JsonObject? workflow; + try + { + workflow = JsonNode.Parse(await File.ReadAllTextAsync(file)) as JsonObject; + } + catch (Exception) + { + continue; + } + + if (workflow is null) + continue; + + foreach (var pack in WorkflowNodeAnalyzer.GetRequiredPacks(workflow)) + { + var key = pack.CnrId ?? pack.AuxId ?? "unidentified"; + if (requiredPacks.TryGetValue(key, out var existing)) + { + existing.NodeTypes.UnionWith(pack.NodeTypes); + } + else + { + requiredPacks[key] = pack; + } + } + } + + var manifestExtensions = new List(); + var installedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (SelectedPackagePair is { BasePackage.ExtensionManager: { } extensionManager } pair) + { + var manifestMap = await extensionManager.GetManifestExtensionsMapAsync( + extensionManager.GetManifests(pair.InstalledPackage) + ); + manifestExtensions = manifestMap.Values.ToList(); + + var installed = await extensionManager.GetInstalledExtensionsLiteAsync(pair.InstalledPackage); + + foreach (var extension in installed) + { + if (extension.PrimaryPath?.Name is { } dirName) + { + installedNames.Add(dirName); + } + + if (GetRepoName(extension.GitRepositoryUrl) is { } repoName) + { + installedNames.Add(repoName); + } + } + } + + Packs = new ObservableCollection( + requiredPacks + .Values.OrderBy(pack => pack.DisplayName) + .Select(pack => + { + var extension = WorkflowNodeAnalyzer.MatchExtension(pack, manifestExtensions); + var isInstalled = + (pack.CnrId is { } cnrId && installedNames.Contains(cnrId)) + || ( + pack.AuxId?.Split('/').Last() is { } auxName + && installedNames.Contains(auxName) + ) + || ( + extension is { Reference: { } reference } + && GetRepoName(reference.ToString()) is { } extensionRepo + && installedNames.Contains(extensionRepo) + ); + + return new WorkflowNodePackViewModel + { + Pack = pack, + Extension = extension, + IsInstalled = isInstalled, + IsSelected = !isInstalled && extension is not null, + }; + }) + ); + } + finally + { + IsLoading = false; + } + } + + private static string? GetRepoName(string? url) => + string.IsNullOrEmpty(url) + ? null + : url.TrimEnd('/').Split('/').LastOrDefault()?.Replace(".git", string.Empty); + + /// + /// Shows the dialog for the given workflow files and runs the selected installs. + /// + public static async Task ShowDialogAsync( + ISettingsManager settingsManager, + IPackageFactory packageFactory, + IReadOnlyList workflowFiles, + bool onlyWhenActionable = false + ) + { + var vm = new WorkflowNodesDialogViewModel(settingsManager, packageFactory) + { + WorkflowFiles = workflowFiles, + }; + + if (onlyWhenActionable) + { + // Skip the dialog when there's nothing the user could do: everything is + // already installed or can't be installed from the index anyway + await vm.EnsureInitializedAsync(); + if (!vm.Packs.Any(pack => pack.CanInstall)) + return; + } + + var dialog = new BetterContentDialog + { + IsPrimaryButtonEnabled = true, + IsSecondaryButtonEnabled = true, + PrimaryButtonText = Languages.Resources.Action_InstallSelected, + SecondaryButtonText = Languages.Resources.Action_Close, + DefaultButton = ContentDialogButton.Primary, + IsFooterVisible = true, + MaxDialogWidth = 600, + MaxDialogHeight = 700, + CloseOnClickOutside = true, + Content = vm, + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + return; + + var steps = vm.GetInstallSteps(); + if (steps.Count == 0) + return; + + var runner = new PackageModificationRunner + { + ShowDialogOnStart = true, + ModificationCompleteTitle = Languages.Resources.Progress_InstallationComplete, + ModificationCompleteMessage = Languages.Resources.Progress_InstallationComplete, + }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + await runner.ExecuteSteps(steps); + } +} + +public partial class WorkflowNodePackViewModel : ObservableObject +{ + public required WorkflowNodePack Pack { get; init; } + + /// Matched extension index entry, null when the pack isn't in the index. + public required PackageExtension? Extension { get; init; } + + public required bool IsInstalled { get; init; } + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public bool CanInstall => !IsInstalled && Extension is not null; + + public bool IsNotInIndex => !IsInstalled && Extension is null && !Pack.IsUnidentified; + + public string NodeTypesText => string.Join(", ", Pack.NodeTypes); +} diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml new file mode 100644 index 000000000..0878d1a7b --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs new file mode 100644 index 000000000..c4d1216b6 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs @@ -0,0 +1,13 @@ +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Controls; + +namespace StabilityMatrix.Avalonia.Views.Dialogs; + +[RegisterTransient] +public partial class WorkflowNodesDialog : UserControlBase +{ + public WorkflowNodesDialog() + { + InitializeComponent(); + } +} diff --git a/StabilityMatrix.Core/Helper/WorkflowNodeAnalyzer.cs b/StabilityMatrix.Core/Helper/WorkflowNodeAnalyzer.cs new file mode 100644 index 000000000..57e405442 --- /dev/null +++ b/StabilityMatrix.Core/Helper/WorkflowNodeAnalyzer.cs @@ -0,0 +1,144 @@ +using System.Text.Json.Nodes; +using StabilityMatrix.Core.Models.Packages.Extensions; + +namespace StabilityMatrix.Core.Helper; + +/// +/// Extracts the custom node packs a ComfyUI workflow file requires, from the +/// cnr_id / aux_id properties ComfyUI embeds per node when saving. +/// +public static class WorkflowNodeAnalyzer +{ + /// The registry id ComfyUI uses for its built-in nodes. + private const string ComfyCoreId = "comfy-core"; + + /// + /// Returns the custom node packs referenced by the workflow, one entry per distinct pack. + /// Nodes without pack information (workflows saved by older ComfyUI versions) are grouped + /// into a single entry with set. + /// + public static IReadOnlyList GetRequiredPacks(JsonObject workflow) + { + if (workflow["nodes"] is not JsonArray nodes) + return []; + + var packs = new Dictionary(StringComparer.OrdinalIgnoreCase); + var unidentifiedTypes = new SortedSet(StringComparer.OrdinalIgnoreCase); + + foreach (var node in nodes.OfType()) + { + var nodeType = node["type"]?.GetValue(); + var properties = node["properties"] as JsonObject; + var cnrId = properties?["cnr_id"]?.GetValue(); + var auxId = properties?["aux_id"]?.GetValue(); + + if (string.Equals(cnrId, ComfyCoreId, StringComparison.OrdinalIgnoreCase)) + continue; + + if (cnrId is null && auxId is null) + { + // Built-in node types are indistinguishable from custom ones here, so only + // surface these when the workflow has no pack info at all (legacy format) + if (nodeType is not null) + { + unidentifiedTypes.Add(nodeType); + } + + continue; + } + + var key = cnrId ?? auxId!; + if (!packs.TryGetValue(key, out var pack)) + { + pack = new WorkflowNodePack { CnrId = cnrId, AuxId = auxId }; + packs[key] = pack; + } + + if (nodeType is not null) + { + pack.NodeTypes.Add(nodeType); + } + } + + var result = packs.Values.ToList(); + + // Only report unidentified nodes for legacy workflows - in a modern file every node + // carries an id, so a missing one would just be noise next to real pack entries + if (result.Count == 0 && unidentifiedTypes.Count > 0) + { + result.Add(new WorkflowNodePack { IsUnidentified = true, NodeTypes = [.. unidentifiedTypes] }); + } + + return result; + } + + /// + /// Finds the manifest extension for a pack: registry id match first, then repo/title + /// name matches against and + /// . Returns null when the pack isn't in the index. + /// + public static PackageExtension? MatchExtension( + WorkflowNodePack pack, + IReadOnlyCollection extensions + ) + { + if (pack.IsUnidentified) + return null; + + if (pack.CnrId is { } cnrId) + { + if ( + extensions.FirstOrDefault(x => + string.Equals(x.Id, cnrId, StringComparison.OrdinalIgnoreCase) + ) is + { } byId + ) + { + return byId; + } + } + + foreach (var name in new[] { pack.AuxId?.Split('/').Last(), pack.CnrId }) + { + if (string.IsNullOrEmpty(name)) + continue; + + var match = extensions.FirstOrDefault(x => + string.Equals(GetRepoName(x.Reference), name, StringComparison.OrdinalIgnoreCase) + || string.Equals(x.Title, name, StringComparison.OrdinalIgnoreCase) + ); + + if (match is not null) + return match; + } + + return null; + } + + private static string? GetRepoName(Uri? reference) => + reference?.ToString().TrimEnd('/').Split('/').LastOrDefault(); +} + +/// +/// A custom node pack referenced by a workflow file. +/// +public class WorkflowNodePack +{ + /// Comfy Node Registry id (e.g. "comfyui-kjnodes"), if the nodes carried one. + public string? CnrId { get; init; } + + /// Fallback pack name ComfyUI records for non-registry installs, often the repo name. + public string? AuxId { get; init; } + + /// + /// True for the synthetic entry grouping nodes of a legacy workflow that carries + /// no pack information at all. + /// + public bool IsUnidentified { get; init; } + + /// Distinct node types the workflow uses from this pack. + public SortedSet NodeTypes { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// Best display name for the pack. + public string DisplayName => AuxId?.Split('/').Last() ?? CnrId ?? "Unknown nodes"; +} diff --git a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs index 89e79c93c..90ebee1df 100644 --- a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs @@ -11,9 +11,10 @@ namespace StabilityMatrix.Core.Models.PackageModification; /// -/// Downloads a CivitAI workflow file (a zip of ComfyUI workflow jsons, or a bare json) and -/// imports the contained workflows into the shared workflow library, embedding -/// in each file for the installed workflows page. +/// Downloads a CivitAI workflow file - an archive or bare file of workflow jsons and/or +/// workflow-embedded images - and imports the contained workflows into the shared workflow +/// library, embedding in each file for the installed +/// workflows page. /// public class DownloadCivitWorkflowStep( CivitModel model, @@ -25,6 +26,11 @@ ISettingsManager settingsManager { public string ProgressTitle => "Downloading Workflow"; + /// + /// Library paths of the workflow files this step imported, populated during execution. + /// + public List ImportedFiles { get; } = []; + public async Task ExecuteAsync(IProgress? progress = null) { var tempFile = new FilePath( @@ -218,6 +224,7 @@ bool isMultiple var filePath = targetDir.JoinFile($"{SanitizeFileName(name)}.json"); await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(workflow)).ConfigureAwait(false); + ImportedFiles.Add(filePath); return true; } diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs b/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs index 9cc768287..81a47cec1 100644 --- a/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs +++ b/StabilityMatrix.Core/Models/Packages/Extensions/ComfyExtensionManifest.cs @@ -8,23 +8,23 @@ public record ComfyExtensionManifest public IEnumerable GetPackageExtensions() { - return CustomNodes.Select( - x => - new PackageExtension - { - Author = x.Author, - Title = x.Title, - Reference = x.Reference, - Files = x.Files, - Pip = x.Pip, - Description = x.Description, - InstallType = x.InstallType - } - ); + return CustomNodes.Select(x => new PackageExtension + { + Id = x.Id, + Author = x.Author, + Title = x.Title, + Reference = x.Reference, + Files = x.Files, + Pip = x.Pip, + Description = x.Description, + InstallType = x.InstallType, + }); } public record ManifestEntry { + public string? Id { get; init; } + public required string Author { get; init; } public required string Title { get; init; } diff --git a/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs b/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs index ae50ccca3..83893b2d1 100644 --- a/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs +++ b/StabilityMatrix.Core/Models/Packages/Extensions/PackageExtension.cs @@ -2,6 +2,12 @@ public record PackageExtension { + /// + /// Comfy Node Registry id (e.g. "comfyui-kjnodes") when the manifest provides one. + /// Matches the cnr_id that ComfyUI embeds per node in workflow files. + /// + public string? Id { get; init; } + public required string Author { get; init; } public required string Title { get; init; } diff --git a/StabilityMatrix.Tests/Core/WorkflowNodeAnalyzerTests.cs b/StabilityMatrix.Tests/Core/WorkflowNodeAnalyzerTests.cs new file mode 100644 index 000000000..ace95502b --- /dev/null +++ b/StabilityMatrix.Tests/Core/WorkflowNodeAnalyzerTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Nodes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.Packages.Extensions; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class WorkflowNodeAnalyzerTests +{ + private static JsonObject Workflow(params string[] nodeJsons) => + (JsonObject)JsonNode.Parse($$"""{"nodes": [{{string.Join(",", nodeJsons)}}]}""")!; + + [TestMethod] + public void GroupsNodesByPack_SkippingComfyCore() + { + var workflow = Workflow( + """{"type": "LoadImage", "properties": {"cnr_id": "comfy-core"}}""", + """{"type": "KJNodesA", "properties": {"cnr_id": "comfyui-kjnodes"}}""", + """{"type": "KJNodesB", "properties": {"cnr_id": "comfyui-kjnodes", "aux_id": "ComfyUI-KJNodes"}}""", + """{"type": "UmeNode", "properties": {"aux_id": "ComfyUI-UmeAiRT-Toolkit"}}""" + ); + + var packs = WorkflowNodeAnalyzer.GetRequiredPacks(workflow); + + Assert.AreEqual(2, packs.Count); + + var kjNodes = packs.Single(p => p.CnrId == "comfyui-kjnodes"); + Assert.AreEqual(2, kjNodes.NodeTypes.Count); + + var ume = packs.Single(p => p.AuxId == "ComfyUI-UmeAiRT-Toolkit"); + Assert.AreEqual("ComfyUI-UmeAiRT-Toolkit", ume.DisplayName); + } + + [TestMethod] + public void LegacyWorkflowWithoutIds_YieldsSingleUnidentifiedPack() + { + var workflow = Workflow( + """{"type": "SomeCustomNode", "properties": {}}""", + """{"type": "AnotherNode"}""" + ); + + var packs = WorkflowNodeAnalyzer.GetRequiredPacks(workflow); + + Assert.AreEqual(1, packs.Count); + Assert.IsTrue(packs[0].IsUnidentified); + Assert.AreEqual(2, packs[0].NodeTypes.Count); + } + + [TestMethod] + public void ModernWorkflow_DoesNotReportUnidentifiedNoise() + { + // A modern file where one node lacks ids should not produce an extra + // "unknown" entry next to the real pack entries + var workflow = Workflow( + """{"type": "KJNode", "properties": {"cnr_id": "comfyui-kjnodes"}}""", + """{"type": "Reroute", "properties": {}}""" + ); + + var packs = WorkflowNodeAnalyzer.GetRequiredPacks(workflow); + + Assert.AreEqual(1, packs.Count); + Assert.AreEqual("comfyui-kjnodes", packs[0].CnrId); + } + + [TestMethod] + public void MatchExtension_PrefersRegistryId_ThenRepoName() + { + var byId = MakeExtension(id: "comfyui-kjnodes", repo: "SomethingElse", title: "Other"); + var byRepo = MakeExtension(id: null, repo: "ComfyUI-UmeAiRT-Toolkit", title: "Ume Toolkit"); + var extensions = new[] { byId, byRepo }; + + var idPack = new WorkflowNodePack { CnrId = "comfyui-kjnodes" }; + Assert.AreSame(byId, WorkflowNodeAnalyzer.MatchExtension(idPack, extensions)); + + var auxPack = new WorkflowNodePack { AuxId = "UmeAiRT/ComfyUI-UmeAiRT-Toolkit" }; + Assert.AreSame(byRepo, WorkflowNodeAnalyzer.MatchExtension(auxPack, extensions)); + + var missingPack = new WorkflowNodePack { CnrId = "not-in-index" }; + Assert.IsNull(WorkflowNodeAnalyzer.MatchExtension(missingPack, extensions)); + } + + private static PackageExtension MakeExtension(string? id, string repo, string title) => + new() + { + Id = id, + Author = "author", + Title = title, + Reference = new Uri($"https://github.com/author/{repo}"), + Files = [new Uri($"https://github.com/author/{repo}")], + }; +} From cfbf8dbc571fb68779f0d118219c1a6264e8207a Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 16 Aug 2026 21:04:29 -0700 Subject: [PATCH 13/27] Explain WebUI-parameter images when a workflow import finds nothing Some workflow packs ship showcase renders generated by A1111/Forge, which carry only a "parameters" text chunk - there is no ComfyUI workflow in the file at all (ComfyUI's drag-import appears to work by synthesizing a default graph from those parameters). Importing such a file now says exactly that instead of the generic no-workflows error, so users know the creator didn't attach the actual workflow. Co-Authored-By: Claude Fable 5 --- .../DownloadCivitWorkflowStep.cs | 25 +++++++++++++++---- .../Core/DownloadCivitWorkflowStepTests.cs | 24 +++++++++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs index 90ebee1df..3bffd45eb 100644 --- a/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/DownloadCivitWorkflowStep.cs @@ -31,6 +31,8 @@ ISettingsManager settingsManager /// public List ImportedFiles { get; } = []; + private bool foundGenerationParametersOnly; + public async Task ExecuteAsync(IProgress? progress = null) { var tempFile = new FilePath( @@ -75,8 +77,12 @@ await File.ReadAllTextAsync(tempFile).ConfigureAwait(false), if (importedCount == 0) { throw new InvalidOperationException( - $"No ComfyUI workflows found in \"{file.Name}\" - " - + "the file contains neither workflow json nor images with an embedded workflow" + foundGenerationParametersOnly + ? $"\"{file.Name}\" contains images with WebUI-style generation parameters, " + + "but no embedded ComfyUI workflow - the creator may not have attached " + + "the actual workflow to this file" + : $"No ComfyUI workflows found in \"{file.Name}\" - " + + "the file contains neither workflow json nor images with an embedded workflow" ); } @@ -168,15 +174,24 @@ bool isMultiple { using var reader = new BinaryReader(new MemoryStream(pngBytes)); workflowJson = ImageMetadata.ReadTextChunk(reader, "workflow"); + + if (string.IsNullOrEmpty(workflowJson)) + { + // Distinguish A1111/Forge-style showcase renders for a clearer error when + // nothing in the download turns out to be importable + if (!string.IsNullOrEmpty(ImageMetadata.ReadTextChunk(reader, "parameters"))) + { + foundGenerationParametersOnly = true; + } + + return false; + } } catch (Exception) { return false; } - if (string.IsNullOrEmpty(workflowJson)) - return false; - if (!await ImportWorkflowJsonAsync(workflowJson, name, targetDir, isMultiple).ConfigureAwait(false)) return false; diff --git a/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs b/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs index b8883a366..a4df15d99 100644 --- a/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs +++ b/StabilityMatrix.Tests/Core/DownloadCivitWorkflowStepTests.cs @@ -159,6 +159,19 @@ public async Task ImportsWorkflowEmbeddedInPng_KeepingImageAsPreview() Assert.IsTrue(File.Exists(sidecar)); } + [TestMethod] + public async Task ExplainsWebUiParameterImages_WhenNothingImportable() + { + // A1111/Forge showcase renders carry only a "parameters" chunk - no workflow + SetupDownloadBytes(BuildPngWithChunk("parameters", "masterpiece, steps: 20")); + + var exception = await Assert.ThrowsExceptionAsync(() => + CreateStep(fileName: "showcase.png").ExecuteAsync() + ); + + StringAssert.Contains(exception.Message, "generation parameters"); + } + [TestMethod] public async Task ImportsPngsFromZip_SkippingImagesWithoutEmbeddedWorkflow() { @@ -193,11 +206,14 @@ private DownloadCivitWorkflowStep CreateStep(string fileName) => private void SetupDownloadBytes(byte[] content) => SetupDownload(content); + private static byte[] BuildPngWithWorkflow(string? workflowJson) => + workflowJson is null ? BuildPngWithChunk(null, null) : BuildPngWithChunk("workflow", workflowJson); + /// - /// Minimal PNG: signature + optional "workflow" tEXt chunk + IEND. Chunk CRCs are + /// Minimal PNG: signature + optional tEXt chunk + IEND. Chunk CRCs are /// zeroed - the metadata reader walks chunks without validating them. /// - private static byte[] BuildPngWithWorkflow(string? workflowJson) + private static byte[] BuildPngWithChunk(string? keyword, string? text) { using var stream = new MemoryStream(); stream.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); @@ -212,9 +228,9 @@ void WriteChunk(string type, byte[] data) stream.Write(new byte[4]); // crc, unvalidated } - if (workflowJson is not null) + if (keyword is not null) { - WriteChunk("tEXt", System.Text.Encoding.UTF8.GetBytes($"workflow\0{workflowJson}")); + WriteChunk("tEXt", System.Text.Encoding.UTF8.GetBytes($"{keyword}\0{text}")); } WriteChunk("IEND", []); From b19bb294898474e654dd649fe5b2af5f14ae34b3 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 17 Aug 2026 15:56:46 -0700 Subject: [PATCH 14/27] Merge pull request #1348 from ionite34/feat/uia-automation-names Accessible names for icon-only controls + --overlay-popups flag (cherry picked from commit 477555ac27c99b4126b12f912b131f5f40850f21) --- StabilityMatrix.Avalonia/Models/AppArgs.cs | 8 +++ StabilityMatrix.Avalonia/Program.cs | 49 +++++-------------- .../ControlThemes/DocsHelpButtonStyles.axaml | 1 + .../Views/MainWindow.axaml | 7 +++ .../MainPackageManagerView.axaml | 16 +++++- .../AutomationNameTests.cs | 47 ++++++++++++++++++ 6 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 StabilityMatrix.UITests/AutomationNameTests.cs diff --git a/StabilityMatrix.Avalonia/Models/AppArgs.cs b/StabilityMatrix.Avalonia/Models/AppArgs.cs index 66441ad34..ab11f68fd 100644 --- a/StabilityMatrix.Avalonia/Models/AppArgs.cs +++ b/StabilityMatrix.Avalonia/Models/AppArgs.cs @@ -74,6 +74,14 @@ public class AppArgs [Option("vulkan", HelpText = "Prefer Vulkan rendering")] public bool UseVulkanRendering { get; set; } + /// + /// Flag to render popups (flyouts, dropdowns, menus) inside the window instead of as + /// separate OS windows, so window captures and UI automation can see them. + /// Popups are clipped to the window bounds in this mode. + /// + [Option("overlay-popups", HelpText = "Render popups inside the window (for UI automation / capture)")] + public bool UseOverlayPopups { get; set; } + /// /// Override global app home directory /// Defaults to (%APPDATA%|~/.config)/StabilityMatrix diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 25b17cf31..838b018d0 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -236,56 +236,31 @@ public static AppBuilder BuildAvaloniaApp() var app = AppBuilder.Configure().UsePlatformDetect().WithInterFont().LogToTrace(); - if (Compat.IsLinux) - { - app = app.With(new X11PlatformOptions { OverlayPopups = true, WmClass = "stabilitymatrix" }); - } - else if (Compat.IsMacOS) - { - app = app.With(new AvaloniaNativePlatformOptions { OverlayPopups = true }); - } + // .With() replaces the registered options instance wholesale, so each platform's + // options must be composed into a single object before applying. + var win32Options = new Win32PlatformOptions { OverlayPopups = Args.UseOverlayPopups }; + var x11Options = new X11PlatformOptions { OverlayPopups = true, WmClass = "stabilitymatrix" }; + var macOptions = new AvaloniaNativePlatformOptions { OverlayPopups = true }; if (Args.UseOpenGlRendering) { - app = app.With( - new Win32PlatformOptions - { - RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software], - } - ); + win32Options.RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software]; } if (Args.UseVulkanRendering) { - app = app.With( - new X11PlatformOptions - { - RenderingMode = [X11RenderingMode.Vulkan], - WmClass = "stabilitymatrix", - } - ) - .With(new Win32PlatformOptions { RenderingMode = [Win32RenderingMode.Vulkan] }); + win32Options.RenderingMode = [Win32RenderingMode.Vulkan]; + x11Options.RenderingMode = [X11RenderingMode.Vulkan]; } if (Args.DisableGpuRendering) { - app = app.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } }) - .With( - new X11PlatformOptions - { - RenderingMode = new[] { X11RenderingMode.Software }, - WmClass = "stabilitymatrix", - } - ) - .With( - new AvaloniaNativePlatformOptions - { - RenderingMode = new[] { AvaloniaNativeRenderingMode.Software }, - } - ); + win32Options.RenderingMode = [Win32RenderingMode.Software]; + x11Options.RenderingMode = [X11RenderingMode.Software]; + macOptions.RenderingMode = [AvaloniaNativeRenderingMode.Software]; } - return app; + return app.With(win32Options).With(x11Options).With(macOptions); } private static void HandleUpdateReplacement() diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml index 5d5aeaee6..80d6b8f6e 100644 --- a/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml @@ -25,6 +25,7 @@ + diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml b/StabilityMatrix.Avalonia/Views/MainWindow.axaml index 30ecc7b21..c6b9900df 100644 --- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml +++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml @@ -38,6 +38,13 @@ + + + + + + Classes="transparent" + ToolTip.Tip="More Options"> @@ -347,6 +349,7 @@