diff --git a/.gitignore b/.gitignore index 3bb999538..30899a833 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # defaults +venv/ __pycache__ .ruff_cache -/cache.json /*.json /*.yaml /params.txt @@ -9,13 +9,14 @@ __pycache__ /user.css /webui-user.bat /webui-user.sh -/html/extensions.json -/html/themes.json +/data/metadata.json +/data/extensions.json +/data/cache.json +/data/themes.json config_states node_modules pnpm-lock.yaml package-lock.json -venv .history cache **/.DS_Store @@ -65,6 +66,7 @@ tunableop_results*.csv .*/ # force included +!/data !/models/VAE-approx !/models/VAE-approx/model.pt !/models/Reference diff --git a/.pylintrc b/.pylintrc index 5f22da840..e4b0a6b4b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -36,6 +36,7 @@ ignore-paths=/usr/lib/.*$, modules/taesd, modules/teacache, modules/todo, + modules/res4lyf, pipelines/bria, pipelines/flex2, pipelines/f_lite, diff --git a/.ruff.toml b/.ruff.toml index 7bd68e119..9b0bf30d4 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,3 +1,6 @@ +line-length = 250 +indent-width = 4 +target-version = "py310" exclude = [ "venv", ".git", @@ -41,9 +44,6 @@ exclude = [ "extensions-builtin/sd-webui-agent-scheduler", "extensions-builtin/sdnext-modernui/node_modules", ] -line-length = 250 -indent-width = 4 -target-version = "py310" [lint] select = [ diff --git a/.vscode/launch.json b/.vscode/launch.json index ea264d51d..d85f85ea9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,6 @@ "env": { "USED_VSCODE_COMMAND_PICKARGS": "1" }, "args": [ "--uv", - "--quick", "--log", "vscode.log", "${command:pickArgs}"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index b387838b7..916732832 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "files.eol": "\n", "python.analysis.extraPaths": [".", "./modules", "./scripts", "./pipelines"], "python.analysis.typeCheckingMode": "off", "editor.formatOnSave": false, diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc2951c7..a56e32e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Change Log for SD.Next +## Update for 2026-02-04 + +### Highlights for 2026-02-04 + +Refresh release two weeks after prior release, yet we still somehow managed to pack in *~150 commits*! +Highlights would be two new models: **Z-Image-Base** and **Anima**, *captioning* support for **tagger** models and a massive addition of new **schedulers** +Also here are updates to `torch` and additional GPU archs support for `ROCm` backends, plus a lot of internal improvements and fixes. + +[ReadMe](https://github.com/vladmandic/automatic/blob/master/README.md) | [ChangeLog](https://github.com/vladmandic/automatic/blob/master/CHANGELOG.md) | [Docs](https://vladmandic.github.io/sdnext-docs/) | [WiKi](https://github.com/vladmandic/automatic/wiki) | [Discord](https://discord.com/invite/sd-next-federal-batch-inspectors-1101998836328697867) | [Sponsor](https://github.com/sponsors/vladmandic) + +### Details for 2026-02-04 + +- **Models** + - [Tongyi-MAI Z-Image Base](https://tongyi-mai.github.io/Z-Image-blog/) + yup, its finally here, the full base model of **Z-Image** + - [CircleStone Anima](https://huggingface.co/circlestone-labs/Anima) + 2B anime optimized model based on a modified Cosmos-Predict, using Qwen3-0.6B as a text encoder +- **Features** + - **caption** tab support for Booru tagger models, thanks @CalamitousFelicitousness + - add SmilingWolf WD14/WaifuDiffusion tagger models, thanks @CalamitousFelicitousness + - support comments in wildcard files, using `#` + - support aliases in metadata skip params, thanks @CalamitousFelicitousness + - ui gallery improve cache cleanup and add manual option, thanks @awsr + - selectable options to add system info to metadata, thanks @Athari + see *settings -> image metadata* +- **Schedulers** + - schedulers documentation has new home: + - add 13(!) new scheduler families + not a port, but more of inspired-by [res4lyf](https://github.com/ClownsharkBatwing/RES4LYF) library + all schedulers should be compatible with both `epsilon` and `flow` prediction style! + *note*: each family may have multiple actual schedulers, so the list total is 56(!) new schedulers + - core family: *RES* + - exponential: *DEIS, ETD, Lawson, ABNorsett* + - integrators: *Runge-Kutta, Linear-RK, Specialized-RK, Lobatto, Radau-IIA, Gauss-Legendre* + - flow: *PEC, Riemannian, Euclidean, Hyperbolic, Lorentzian, Langevin-Dynamics* + - add 3 additional schedulers: *CogXDDIM, DDIMParallel, DDPMParallel* + not originally intended to be a general purpose schedulers, but they work quite nicely and produce good results + - image metadata: always log scheduler class used +- **API** + - add `/sdapi/v1/xyz-grid` to enumerate xyz-grid axis options and their choices + see `/cli/api-xyzenum.py` for example usage + - add `/sdapi/v1/sampler` to get current sampler config + - modify `/sdapi/v1/samplers` to enumerate available samplers possible options + see `/cli/api-samplers.py` for example usage +- **Internal** + - tagged release history: + each major for the past year is now tagged for easier reference + - **torch** update + *note*: may cause slow first startup/generate + **cuda**: update to `torch==2.10.0` + **xpu**: update to `torch==2.10.0` + **rocm**: update to `torch==2.10.0` + **openvino**: update to `torch==2.10.0` and `openvino==2025.4.1` + - rocm: expand available gfx archs, thanks @crashingalexsan + - rocm: set `MIOPEN_FIND_MODE=2` by default, thanks @crashingalexsan + - relocate all json data files to `data/` folder + existing data files are auto-migrated on startup + - refactor and improve connection monitor, thanks @awsr + - further work on type consistency and type checking, thanks @awsr + - log captured exceptions + - improve temp folder handling and cleanup + - remove torch errors/warings on fast server shutdown + - add ui placeholders for future agent-scheduler work, thanks @ryanmeador + - implement abort system on repeated errors, thanks @awsr + currently used by lora and textual-inversion loaders + - update package requirements +- **Fixes** + - add video ui elem_ids, thanks @ryanmeador + - use base steps as-is for non sd/sdxl models + - ui css fixes for modernui + - support lora inside prompt selector + - framepack video save + - metadata save for manual saves + ## Update for 2026-01-22 Bugfix refresh @@ -139,7 +213,7 @@ End of year release update, just two weeks after previous one, with several new - **Models** - [LongCat Image](https://github.com/meituan-longcat/LongCat-Image) in *Image* and *Image Edit* variants LongCat is a new 8B diffusion base model using Qwen-2.5 as text encoder - - [Qwen-Image-Edit 2511](Qwen/Qwen-Image-Edit-2511) in *base* and *pre-quantized* variants + - [Qwen-Image-Edit 2511](https://huggingface.co/Qwen/Qwen-Image-Edit-2511) in *base* and *pre-quantized* variants Key enhancements: mitigate image drift, improved character consistency, enhanced industrial design generation, and strengthened geometric reasoning ability - [Qwen-Image-Layered](https://huggingface.co/Qwen/Qwen-Image-Layered) in *base* and *pre-quantized* variants Qwen-Image-Layered, a model capable of decomposing an image into multiple RGBA layers diff --git a/README.md b/README.md index b81f1a8aa..8fc0b24e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
SD.Next -# SD.Next: All-in-one WebUI for AI generative image and video creation +# SD.Next: All-in-one WebUI for AI generative image and video creation and captioning ![Last update](https://img.shields.io/github/last-commit/vladmandic/sdnext?svg=true) ![License](https://img.shields.io/github/license/vladmandic/sdnext?svg=true) @@ -27,10 +27,8 @@ All individual features are not listed here, instead check [ChangeLog](CHANGELOG.md) for full list of changes - Fully localized: ▹ **English | Chinese | Russian | Spanish | German | French | Italian | Portuguese | Japanese | Korean** -- Multiple UIs! - ▹ **Standard | Modern** +- Desktop and Mobile support! - Multiple [diffusion models](https://vladmandic.github.io/sdnext-docs/Model-Support/)! -- Built-in Control for Text, Image, Batch and Video processing! - Multi-platform! ▹ **Windows | Linux | MacOS | nVidia CUDA | AMD ROCm | Intel Arc / IPEX XPU | DirectML | OpenVINO | ONNX+Olive | ZLUDA** - Platform specific auto-detection and tuning performed on install @@ -38,9 +36,7 @@ All individual features are not listed here, instead check [ChangeLog](CHANGELOG Compile backends: *Triton | StableFast | DeepCache | OneDiff | TeaCache | etc.* Quantization methods: *SDNQ | BitsAndBytes | Optimum-Quanto | TorchAO / LayerWise* - **Interrogate/Captioning** with 150+ **OpenCLiP** models and 20+ built-in **VLMs** -- Built-in queue management - Built in installer with automatic updates and dependency management -- Mobile compatible
diff --git a/TODO.md b/TODO.md index 4c3f94f73..2cb6503ff 100644 --- a/TODO.md +++ b/TODO.md @@ -1,107 +1,137 @@ # TODO -## Project Board - -- - ## Internal -- Feature: Move `nunchaku` models to refernce instead of internal decision -- Update: `transformers==5.0.0` -- Feature: Unify *huggingface* and *diffusers* model folders -- Reimplement `llama` remover for Kanvas +- Update: `transformers==5.0.0`, owner @CalamitousFelicitousness - Deploy: Create executable for SD.Next -- Feature: Integrate natural language image search - [ImageDB](https://github.com/vladmandic/imagedb) -- Feature: Remote Text-Encoder support -- Refactor: move sampler options to settings to config -- Refactor: [GGUF](https://huggingface.co/docs/diffusers/main/en/quantization/gguf) -- Feature: LoRA add OMI format support for SD35/FLUX.1 -- Refactor: remove `CodeFormer` -- Refactor: remove `GFPGAN` -- UI: Lite vs Expert mode -- Video tab: add full API support -- Control tab: add overrides handling -- Engine: `TensorRT` acceleration +- Deploy: Lite vs Expert mode - Engine: [mmgp](https://github.com/deepbeepmeep/mmgp) - Engine: [sharpfin](https://github.com/drhead/sharpfin) instead of `torchvision` +- Engine: `TensorRT` acceleration +- Feature: Auto handle scheduler `prediction_type` +- Feature: Cache models in memory +- Feature: Control tab add overrides handling +- Feature: Integrate natural language image search + [ImageDB](https://github.com/vladmandic/imagedb) +- Feature: LoRA add OMI format support for SD35/FLUX.1, on-hold +- Feature: Multi-user support +- Feature: Remote Text-Encoder support, sidelined for the moment +- Feature: Settings profile manager +- Feature: Video tab add full API support +- Refactor: Unify *huggingface* and *diffusers* model folders +- Refactor: Move `nunchaku` models to refernce instead of internal decision, owner @CalamitousFelicitousness +- Refactor: [GGUF](https://huggingface.co/docs/diffusers/main/en/quantization/gguf) +- Refactor: move sampler options to settings to config +- Refactor: remove `CodeFormer`, owner @CalamitousFelicitousness +- Refactor: remove `GFPGAN`, owner @CalamitousFelicitousness +- Reimplement `llama` remover for Kanvas, pending end-to-end review of `Kanvas` ## Modular +*Pending finalization of modular pipelines implementation and development of compatibility layer* + - Switch to modular pipelines - Feature: Transformers unified cache handler - Refactor: [Modular pipelines and guiders](https://github.com/huggingface/diffusers/issues/11915) -- [MagCache](https://github.com/lllyasviel/FramePack/pull/673/files) +- [MagCache](https://github.com/huggingface/diffusers/pull/12744) - [SmoothCache](https://github.com/huggingface/diffusers/issues/11135) - -## Features - -- [Flux.2 TinyVAE](https://huggingface.co/fal/FLUX.2-Tiny-AutoEncoder) -- [IPAdapter composition](https://huggingface.co/ostris/ip-composition-adapter) -- [IPAdapter negative guidance](https://github.com/huggingface/diffusers/discussions/7167) - [STG](https://github.com/huggingface/diffusers/blob/main/examples/community/README.md#spatiotemporal-skip-guidance) -- [Video Inpaint Pipeline](https://github.com/huggingface/diffusers/pull/12506) -- [Sonic Inpaint](https://github.com/ubc-vision/sonic) -### New models / Pipelines +## New models / Pipelines TODO: Investigate which models are diffusers-compatible and prioritize! -- [Bria FiboEdit](https://github.com/huggingface/diffusers/commit/d7a1c31f4f85bae5a9e01cdce49bd7346bd8ccd6) -- [LTXVideo 0.98 LongMulti](https://github.com/huggingface/diffusers/pull/12614) -- [Cosmos-Predict-2.5](https://huggingface.co/nvidia/Cosmos-Predict2.5-2B) -- [NewBie Image Exp0.1](https://github.com/huggingface/diffusers/pull/12803) -- [Sana-I2V](https://github.com/huggingface/diffusers/pull/12634#issuecomment-3540534268) -- [Bria FIBO](https://huggingface.co/briaai/FIBO) -- [Bytedance Lynx](https://github.com/bytedance/lynx) -- [ByteDance OneReward](https://github.com/bytedance/OneReward) -- [ByteDance USO](https://github.com/bytedance/USO) -- [Chroma Radiance](https://huggingface.co/lodestones/Chroma1-Radiance) -- [Chroma Zeta](https://huggingface.co/lodestones/Zeta-Chroma) -- [DiffSynth Studio](https://github.com/modelscope/DiffSynth-Studio) -- [DiffusionForcing](https://github.com/kwsong0113/diffusion-forcing-transformer) -- [Dream0 guidance](https://huggingface.co/ByteDance/DreamO) -- [HunyuanAvatar](https://huggingface.co/tencent/HunyuanVideo-Avatar) -- [HunyuanCustom](https://github.com/Tencent-Hunyuan/HunyuanCustom) -- [Inf-DiT](https://github.com/zai-org/Inf-DiT) -- [Krea Realtime Video](https://huggingface.co/krea/krea-realtime-video) -- [LanDiff](https://github.com/landiff/landiff) -- [Liquid](https://github.com/FoundationVision/Liquid) -- [LongCat-Video](https://huggingface.co/meituan-longcat/LongCat-Video) -- [LucyEdit](https://github.com/huggingface/diffusers/pull/12340) -- [Lumina-DiMOO](https://huggingface.co/Alpha-VLLM/Lumina-DiMOO) -- [Magi](https://github.com/SandAI-org/MAGI-1)(https://github.com/huggingface/diffusers/pull/11713) -- [Ming](https://github.com/inclusionAI/Ming) -- [MUG-V 10B](https://huggingface.co/MUG-V/MUG-V-inference) -- [Ovi](https://github.com/character-ai/Ovi) -- [Phantom HuMo](https://github.com/Phantom-video/Phantom) -- [SD3 UltraEdit](https://github.com/HaozheZhao/UltraEdit) -- [SelfForcing](https://github.com/guandeh17/Self-Forcing) -- [SEVA](https://github.com/huggingface/diffusers/pull/11440) -- [Step1X](https://github.com/stepfun-ai/Step1X-Edit) -- [Wan-2.2 Animate](https://github.com/huggingface/diffusers/pull/12526) -- [Wan-2.2 S2V](https://github.com/huggingface/diffusers/pull/12258) -- [WAN-CausVid-Plus t2v](https://github.com/goatWu/CausVid-Plus/) -- [WAN-CausVid](https://huggingface.co/lightx2v/Wan2.1-T2V-14B-CausVid) -- [WAN-StepDistill](https://huggingface.co/lightx2v/Wan2.1-T2V-14B-StepDistill-CfgDistill) -- [Wan2.2-Animate-14B](https://huggingface.co/Wan-AI/Wan2.2-Animate-14B) -- [WAN2GP](https://github.com/deepbeepmeep/Wan2GP) +### Upscalers + +- [HQX](https://github.com/uier/py-hqx/blob/main/hqx.py) +- [DCCI](https://every-algorithm.github.io/2024/11/06/directional_cubic_convolution_interpolation.html) +- [ICBI](https://github.com/gyfastas/ICBI/blob/master/icbi.py) + +### Image-Base +- [Chroma Zeta](https://huggingface.co/lodestones/Zeta-Chroma): Image and video generator for creative effects and professional filters +- [Chroma Radiance](https://huggingface.co/lodestones/Chroma1-Radiance): Pixel-space model eliminating VAE artifacts for high visual fidelity +- [Liquid](https://github.com/FoundationVision/Liquid): Unified vision-language auto-regressive generation paradigm +- [Lumina-DiMOO](https://huggingface.co/Alpha-VLLM/Lumina-DiMOO): Foundational multi-modal generation and understanding via discrete diffusion +- [nVidia Cosmos-Predict-2.5](https://huggingface.co/nvidia/Cosmos-Predict2.5-2B): Physics-aware world foundation model for consistent scene prediction +- [Liquid (unified multimodal generator)](https://github.com/FoundationVision/Liquid): Auto-regressive generation paradigm across vision and language +- [Lumina-DiMOO](https://huggingface.co/Alpha-VLLM/Lumina-DiMOO): foundational multi-modal multi-task generation and understanding + +### Image-Edit +- [Meituan LongCat-Image-Edit-Turbo](https://huggingface.co/meituan-longcat/LongCat-Image-Edit-Turbo):6B instruction-following image editing with high visual consistency +- [VIBE Image-Edit](https://huggingface.co/iitolstykh/VIBE-Image-Edit): (Sana+Qwen-VL)Fast visual instruction-based image editing framework +- [LucyEdit](https://github.com/huggingface/diffusers/pull/12340):Instruction-guided video editing while preserving motion and identity +- [Step1X-Edit](https://github.com/stepfun-ai/Step1X-Edit):Multimodal image editing decoding MLLM tokens via DiT +- [OneReward](https://github.com/bytedance/OneReward):Reinforcement learning grounded generative reward model for image editing +- [ByteDance DreamO](https://huggingface.co/ByteDance/DreamO): image customization framework for IP adaptation and virtual try-on + +### Video +- [OpenMOSS MOVA](https://huggingface.co/OpenMOSS-Team/MOVA-720p): Unified foundation model for synchronized high-fidelity video and audio +- [Wan family (Wan2.1 / Wan2.2 variants)](https://huggingface.co/Wan-AI/Wan2.2-Animate-14B): MoE-based foundational tools for cinematic T2V/I2V/TI2V + example: [Wan2.1-T2V-14B-CausVid](https://huggingface.co/lightx2v/Wan2.1-T2V-14B-CausVid) + distill / step-distill examples: [Wan2.1-StepDistill-CfgDistill](https://huggingface.co/lightx2v/Wan2.1-T2V-14B-StepDistill-CfgDistill) +- [Krea Realtime Video](https://huggingface.co/krea/krea-realtime-video): (Wan2.1)Distilled real-time video diffusion using self-forcing techniques +- [MAGI-1 (autoregressive video)](https://github.com/SandAI-org/MAGI-1): Autoregressive video generation allowing infinite and timeline control +- [MUG-V 10B (video generation)](https://huggingface.co/MUG-V/MUG-V-inference): large-scale DiT-based video generation system trained via flow-matching +- [Ovi (audio/video generation)](https://github.com/character-ai/Ovi): (Wan2.2)Speech-to-video with synchronized sound effects and music +- [HunyuanVideo-Avatar / HunyuanCustom](https://huggingface.co/tencent/HunyuanVideo-Avatar): (HunyuanVideo)MM-DiT based dynamic emotion-controllable dialogue generation +- [Sana Image→Video (Sana-I2V)](https://github.com/huggingface/diffusers/pull/12634#issuecomment-3540534268): (Sana)Compact Linear DiT framework for efficient high-resolution video +- [Wan-2.2 S2V (diffusers PR)](https://github.com/huggingface/diffusers/pull/12258): (Wan2.2)Audio-driven cinematic speech-to-video generation +- [LongCat-Video](https://huggingface.co/meituan-longcat/LongCat-Video): Unified framework for minutes-long coherent video generation via Block Sparse Attention +- [LTXVideo / LTXVideo LongMulti (diffusers PR)](https://github.com/huggingface/diffusers/pull/12614): Real-time DiT-based generation with production-ready camera controls +- [DiffSynth-Studio (ModelScope)](https://github.com/modelscope/DiffSynth-Studio): (Wan2.2)Comprehensive training and quantization tools for Wan video models +- [Phantom (Phantom HuMo)](https://github.com/Phantom-video/Phantom): Human-centric video generation framework focus on subject ID consistency +- [CausVid-Plus / WAN-CausVid-Plus](https://github.com/goatWu/CausVid-Plus/): (Wan2.1)Causal diffusion for high-quality temporally consistent long videos +- [Wan2GP (workflow/GUI for Wan)](https://github.com/deepbeepmeep/Wan2GP): (Wan)Web-based UI focused on running complex video models for GPU-poor setups +- [LivePortrait](https://github.com/KwaiVGI/LivePortrait): Efficient portrait animation system with high stitching and retargeting control +- [Magi (SandAI)](https://github.com/SandAI-org/MAGI-1): High-quality autoregressive video generation framework +- [Ming (inclusionAI)](https://github.com/inclusionAI/Ming): Unified multimodal model for processing text, audio, image, and video + +### Other/Unsorted +- [DiffusionForcing](https://github.com/kwsong0113/diffusion-forcing-transformer): Full-sequence diffusion with autoregressive next-token prediction +- [Self-Forcing](https://github.com/guandeh17/Self-Forcing): Framework for improving temporal consistency in long-horizon video generation +- [SEVA](https://github.com/huggingface/diffusers/pull/11440): Stable Virtual Camera for novel view synthesis and 3D-consistent video +- [ByteDance USO](https://github.com/bytedance/USO): Unified Style-Subject Optimized framework for personalized image generation +- [ByteDance Lynx](https://github.com/bytedance/lynx): State-of-the-art high-fidelity personalized video generation based on DiT +- [LanDiff](https://github.com/landiff/landiff): Coarse-to-fine text-to-video integrating Language and Diffusion Models +- [Video Inpaint Pipeline](https://github.com/huggingface/diffusers/pull/12506): Unified inpainting pipeline implementation within Diffusers library +- [Sonic Inpaint](https://github.com/ubc-vision/sonic): Audio-driven portrait animation system focus on global audio perception +- [Make-It-Count](https://github.com/Litalby1/make-it-count): CountGen method for precise numerical control of objects via object identity features +- [ControlNeXt](https://github.com/dvlab-research/ControlNeXt/): Lightweight architecture for efficient controllable image and video generation +- [MS-Diffusion](https://github.com/MS-Diffusion/MS-Diffusion): Layout-guided multi-subject image personalization framework +- [UniRef](https://github.com/FoundationVision/UniRef): Unified model for segmentation tasks designed as foundation model plug-in +- [FlashFace](https://github.com/ali-vilab/FlashFace): High-fidelity human image customization and face swapping framework +- [ReNO](https://github.com/ExplainableML/ReNO): Reward-based Noise Optimization to improve text-to-image quality during inference + +### Not Planned +- [Bria FIBO](https://huggingface.co/briaai/FIBO): Fully JSON based +- [Bria FiboEdit](https://github.com/huggingface/diffusers/commit/d7a1c31f4f85bae5a9e01cdce49bd7346bd8ccd6): Fully JSON based +- [LoRAdapter](https://github.com/CompVis/LoRAdapter): Not recently updated +- [SD3 UltraEdit](https://github.com/HaozheZhao/UltraEdit): Based on SD3 +- [PowerPaint](https://github.com/open-mmlab/PowerPaint): Based on SD15 +- [FreeCustom](https://github.com/aim-uofa/FreeCustom): Based on SD15 +- [AnyDoor](https://github.com/ali-vilab/AnyDoor): Based on SD21 +- [AnyText2](https://github.com/tyxsspa/AnyText2): Based on SD15 +- [DragonDiffusion](https://github.com/MC-E/DragonDiffusion): Based on SD15 +- [DenseDiffusion](https://github.com/naver-ai/DenseDiffusion): Based on SD15 +- [IC-Light](https://github.com/lllyasviel/IC-Light): Based on SD15 + +## Migration ### Asyncio -- Policy system is deprecated and will be removed in **Python 3.16** - - [Python 3.14 removals - asyncio](https://docs.python.org/3.14/whatsnew/3.14.html#id10) - - https://docs.python.org/3.14/library/asyncio-policy.html - - Affected files: - - [`webui.py`](webui.py) - - [`cli/sdapi.py`](cli/sdapi.py) - - Migration: - - [asyncio.run](https://docs.python.org/3.14/library/asyncio-runner.html#asyncio.run) - - [asyncio.Runner](https://docs.python.org/3.14/library/asyncio-runner.html#asyncio.Runner) +- Policy system is deprecated and will be removed in Python 3.16 + [Python 3.14 removalsasyncio](https://docs.python.org/3.14/whatsnew/3.14.html#id10) + https://docs.python.org/3.14/library/asyncio-policy.html + Affected files: + [`webui.py`](webui.py) + [`cli/sdapi.py`](cli/sdapi.py) + Migration: + [asyncio.run](https://docs.python.org/3.14/library/asyncio-runner.html#asyncio.run) + [asyncio.Runner](https://docs.python.org/3.14/library/asyncio-runner.html#asyncio.Runner) -#### rmtree +### rmtree -- `onerror` deprecated and replaced with `onexc` in **Python 3.12** +- `onerror` deprecated and replaced with `onexc` in Python 3.12 ``` python def excRemoveReadonly(func, path, exc: BaseException): import stat diff --git a/cli/api-samplers.py b/cli/api-samplers.py new file mode 100644 index 000000000..c63baf37c --- /dev/null +++ b/cli/api-samplers.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +""" +get list of all samplers and details of current sampler +""" + +import sys +import logging +import urllib3 +import requests + + +url = "http://127.0.0.1:7860" +user = "" +password = "" + +log_format = '%(asctime)s %(levelname)s: %(message)s' +logging.basicConfig(level = logging.INFO, format = log_format) +log = logging.getLogger("sd") +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +log.info('available samplers') +auth = requests.auth.HTTPBasicAuth(user, password) if len(user) > 0 and len(password) > 0 else None +req = requests.get(f'{url}/sdapi/v1/samplers', verify=False, auth=auth, timeout=60) +if req.status_code != 200: + log.error({ 'url': req.url, 'request': req.status_code, 'reason': req.reason }) + exit(1) +res = req.json() +for item in res: + log.info(item) + +log.info('current sampler') +req = requests.get(f'{url}/sdapi/v1/sampler', verify=False, auth=auth, timeout=60) +res = req.json() +log.info(res) diff --git a/cli/api-xyzenum.py b/cli/api-xyzenum.py new file mode 100755 index 000000000..e5eb12f83 --- /dev/null +++ b/cli/api-xyzenum.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +import os +import logging +import requests +import urllib3 + + +sd_url = os.environ.get('SDAPI_URL', "http://127.0.0.1:7860") +sd_username = os.environ.get('SDAPI_USR', None) +sd_password = os.environ.get('SDAPI_PWD', None) +options = { + "save_images": True, + "send_images": True, +} + +logging.basicConfig(level = logging.INFO, format = '%(asctime)s %(levelname)s: %(message)s') +log = logging.getLogger(__name__) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +def auth(): + if sd_username is not None and sd_password is not None: + return requests.auth.HTTPBasicAuth(sd_username, sd_password) + return None + + +def get(endpoint: str, dct: dict = None): + req = requests.get(f'{sd_url}{endpoint}', json = dct, timeout=300, verify=False, auth=auth()) + if req.status_code != 200: + return { 'error': req.status_code, 'reason': req.reason, 'url': req.url } + else: + return req.json() + + +if __name__ == "__main__": + options = get('/sdapi/v1/xyz-grid') + log.info(f'api-xyzgrid-options: {len(options)}') + for option in options: + log.info(f' {option}') + details = get('/sdapi/v1/xyz-grid?option=upscaler') + for choice in details[0]['choices']: + log.info(f' {choice}') diff --git a/cli/test-schedulers.py b/cli/test-schedulers.py new file mode 100644 index 000000000..5faa95446 --- /dev/null +++ b/cli/test-schedulers.py @@ -0,0 +1,260 @@ +import os +import sys +import time +import numpy as np +import torch + +# Ensure we can import modules +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))) + +from modules.errors import log +from modules.res4lyf import ( + BASE, SIMPLE, VARIANTS, + RESUnifiedScheduler, RESMultistepScheduler, RESDEISMultistepScheduler, + ETDRKScheduler, LawsonScheduler, ABNorsettScheduler, PECScheduler, + RiemannianFlowScheduler, RESSinglestepScheduler, RESSinglestepSDEScheduler, + RESMultistepSDEScheduler, SimpleExponentialScheduler, LinearRKScheduler, + LobattoScheduler, GaussLegendreScheduler, RungeKutta44Scheduler, + RungeKutta57Scheduler, RungeKutta67Scheduler, SpecializedRKScheduler, + BongTangentScheduler, CommonSigmaScheduler, RadauIIAScheduler, + LangevinDynamicsScheduler +) +from modules.schedulers.scheduler_vdm import VDMScheduler +from modules.schedulers.scheduler_unipc_flowmatch import FlowUniPCMultistepScheduler +from modules.schedulers.scheduler_ufogen import UFOGenScheduler +from modules.schedulers.scheduler_tdd import TDDScheduler +from modules.schedulers.scheduler_tcd import TCDScheduler +from modules.schedulers.scheduler_flashflow import FlashFlowMatchEulerDiscreteScheduler +from modules.schedulers.scheduler_dpm_flowmatch import FlowMatchDPMSolverMultistepScheduler +from modules.schedulers.scheduler_dc import DCSolverMultistepScheduler +from modules.schedulers.scheduler_bdia import BDIA_DDIMScheduler + +def test_scheduler(name, scheduler_class, config): + try: + scheduler = scheduler_class(**config) + except Exception as e: + log.error(f'scheduler="{name}" cls={scheduler_class} config={config} error="Init failed: {e}"') + return False + + num_steps = 20 + scheduler.set_timesteps(num_steps) + + sample = torch.randn((1, 4, 64, 64)) + has_changed = False + t0 = time.time() + messages = [] + + try: + for i, t in enumerate(scheduler.timesteps): + # Simulate model output (noise or x0 or v), Using random noise for stability check + model_output = torch.randn_like(sample) + + # Scaling Check + step_idx = scheduler.step_index if hasattr(scheduler, "step_index") and scheduler.step_index is not None else i + # Clamp index + if hasattr(scheduler, 'sigmas'): + step_idx = min(step_idx, len(scheduler.sigmas) - 1) + sigma = scheduler.sigmas[step_idx] + else: + sigma = torch.tensor(1.0) # Dummy for non-sigma schedulers + + # Re-introduce scaling calculation first + scaled_sample = scheduler.scale_model_input(sample, t) + + if config.get("prediction_type") == "flow_prediction" or name in ["UFOGenScheduler", "TDDScheduler", "TCDScheduler", "BDIA_DDIMScheduler", "DCSolverMultistepScheduler"]: + # Some new schedulers don't use K-diffusion scaling + expected_scale = 1.0 + else: + expected_scale = 1.0 / ((sigma**2 + 1) ** 0.5) + + # Simple check with loose tolerance due to float precision + expected_scaled_sample = sample * expected_scale + if not torch.allclose(scaled_sample, expected_scaled_sample, atol=1e-4): + # If failed, double check if it's just 'sample' (no scaling) + if torch.allclose(scaled_sample, sample, atol=1e-4): + messages.append('warning="scaling is identity"') + else: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} expected={expected_scale} error="scaling mismatch"') + return False + + if torch.isnan(scaled_sample).any(): + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="NaN in scaled_sample"') + return False + + if torch.isinf(scaled_sample).any(): + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="Inf in scaled_sample"') + return False + + output = scheduler.step(model_output, t, sample) + + # Shape and Dtype check + if output.prev_sample.shape != sample.shape: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="Shape mismatch: {output.prev_sample.shape} vs {sample.shape}"') + return False + if output.prev_sample.dtype != sample.dtype: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="Dtype mismatch: {output.prev_sample.dtype} vs {sample.dtype}"') + return False + + # Update check: Did the sample change? + if not torch.equal(sample, output.prev_sample): + has_changed = True + + # Sample Evolution Check + step_diff = (sample - output.prev_sample).abs().mean().item() + if step_diff < 1e-6: + messages.append(f'warning="minimal sample change: {step_diff}"') + + sample = output.prev_sample + + if torch.isnan(sample).any(): + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="NaN in sample"') + return False + + if torch.isinf(sample).any(): + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="Inf in sample"') + return False + + # Divergence check + if sample.abs().max() > 1e10: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} step={i} error="divergence detected"') + return False + + # External check for Sigma Monotonicity + if hasattr(scheduler, 'sigmas'): + sigmas = scheduler.sigmas.cpu().numpy() + if len(sigmas) > 1: + diffs = np.diff(sigmas) # Check if potentially monotonic decreasing (standard) OR increasing (some flow/inverse setups). We allow flat sections (diff=0) hence 1e-6 slack + is_monotonic_decreasing = np.all(diffs <= 1e-6) + is_monotonic_increasing = np.all(diffs >= -1e-6) + if not (is_monotonic_decreasing or is_monotonic_increasing): + messages.append('warning="sigmas are not monotonic"') + + except Exception as e: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} exception: {e}') + import traceback + traceback.print_exc() + return False + + if not has_changed: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} error="sample never changed"') + return False + + final_std = sample.std().item() + if final_std > 50.0 or final_std < 0.1: + log.error(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} std={final_std} error="variance drift"') + + t1 = time.time() + messages = list(set(messages)) + log.info(f'scheduler="{name}" cls={scheduler.__class__.__name__} config={config} time={t1-t0} messages={messages}') + return True + +def run_tests(): + prediction_types = ["epsilon", "v_prediction", "sample"] # flow_prediction is special, usually requires flow sigmas or specific setup, checking standard ones first + + # Test BASE schedulers with their specific parameters + log.warning('type="base"') + for name, cls in BASE: + configs = [] + + # prediction_types + for pt in prediction_types: + configs.append({"prediction_type": pt}) + + # Specific params for specific classes + if cls == RESUnifiedScheduler: + rk_types = ["res_2m", "res_3m", "res_2s", "res_3s", "res_5s", "res_6s", "deis_1s", "deis_2m", "deis_3m"] + for rk in rk_types: + for pt in prediction_types: + configs.append({"rk_type": rk, "prediction_type": pt}) + + elif cls == RESMultistepScheduler: + variants = ["res_2m", "res_3m", "deis_2m", "deis_3m"] + for v in variants: + for pt in prediction_types: + configs.append({"variant": v, "prediction_type": pt}) + + elif cls == RESDEISMultistepScheduler: + for order in range(1, 6): + for pt in prediction_types: + configs.append({"solver_order": order, "prediction_type": pt}) + + elif cls == ETDRKScheduler: + variants = ["etdrk2_2s", "etdrk3_a_3s", "etdrk3_b_3s", "etdrk4_4s", "etdrk4_4s_alt"] + for v in variants: + for pt in prediction_types: + configs.append({"variant": v, "prediction_type": pt}) + + elif cls == LawsonScheduler: + variants = ["lawson2a_2s", "lawson2b_2s", "lawson4_4s"] + for v in variants: + for pt in prediction_types: + configs.append({"variant": v, "prediction_type": pt}) + + elif cls == ABNorsettScheduler: + variants = ["abnorsett_2m", "abnorsett_3m", "abnorsett_4m"] + for v in variants: + for pt in prediction_types: + configs.append({"variant": v, "prediction_type": pt}) + + elif cls == PECScheduler: + variants = ["pec423_2h2s", "pec433_2h3s"] + for v in variants: + for pt in prediction_types: + configs.append({"variant": v, "prediction_type": pt}) + + elif cls == RiemannianFlowScheduler: + metrics = ["euclidean", "hyperbolic", "spherical", "lorentzian"] + for m in metrics: + configs.append({"metric_type": m, "prediction_type": "epsilon"}) # Flow usually uses v or raw, but epsilon check matches others + + if not configs: + for pt in prediction_types: + configs.append({"prediction_type": pt}) + + for conf in configs: + test_scheduler(name, cls, conf) + + log.warning('type="simple"') + for name, cls in SIMPLE: + for pt in prediction_types: + test_scheduler(name, cls, {"prediction_type": pt}) + + log.warning('type="variants"') + for name, cls in VARIANTS: + # these classes preset their variants/rk_types in __init__ so we just test prediction types + for pt in prediction_types: + test_scheduler(name, cls, {"prediction_type": pt}) + + # Extra robustness check: Flow Prediction Type + log.warning('type="flow"') + flow_schedulers = [ + # res4lyf schedulers + RESUnifiedScheduler, RESMultistepScheduler, ABNorsettScheduler, + RESSinglestepScheduler, RESSinglestepSDEScheduler, RESDEISMultistepScheduler, + RESMultistepSDEScheduler, ETDRKScheduler, LawsonScheduler, PECScheduler, + SimpleExponentialScheduler, LinearRKScheduler, LobattoScheduler, + GaussLegendreScheduler, RungeKutta44Scheduler, RungeKutta57Scheduler, + RungeKutta67Scheduler, SpecializedRKScheduler, BongTangentScheduler, + CommonSigmaScheduler, RadauIIAScheduler, LangevinDynamicsScheduler, + RiemannianFlowScheduler, + # sdnext schedulers + FlowUniPCMultistepScheduler, FlashFlowMatchEulerDiscreteScheduler, FlowMatchDPMSolverMultistepScheduler, + ] + for cls in flow_schedulers: + test_scheduler(cls.__name__, cls, {"prediction_type": "flow_prediction", "use_flow_sigmas": True}) + + log.warning('type="sdnext"') + extended_schedulers = [ + VDMScheduler, + UFOGenScheduler, + TDDScheduler, + TCDScheduler, + DCSolverMultistepScheduler, + BDIA_DDIMScheduler + ] + for prediction_type in ["epsilon", "v_prediction", "sample"]: + for cls in extended_schedulers: + test_scheduler(cls.__name__, cls, {"prediction_type": prediction_type}) + +if __name__ == "__main__": + run_tests() diff --git a/cli/test-tagger.py b/cli/test-tagger.py new file mode 100644 index 000000000..2a41b6ee6 --- /dev/null +++ b/cli/test-tagger.py @@ -0,0 +1,847 @@ +#!/usr/bin/env python +""" +Tagger Settings Test Suite + +Tests all WaifuDiffusion and DeepBooru tagger settings to verify they're properly +mapped and affect output correctly. + +Usage: + python cli/test-tagger.py [image_path] + +If no image path is provided, uses a built-in test image. +""" + +import os +import sys +import time + +# Add parent directory to path for imports +script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, script_dir) +os.chdir(script_dir) + +# Suppress installer output during import +os.environ['SD_INSTALL_QUIET'] = '1' + +# Initialize cmd_args properly with all argument groups +import modules.cmd_args +import installer + +# Add installer args to the parser +installer.add_args(modules.cmd_args.parser) + +# Parse with empty args to get defaults +modules.cmd_args.parsed, _ = modules.cmd_args.parser.parse_known_args([]) + +# Now we can safely import modules that depend on cmd_args + + +# Default test images (in order of preference) +DEFAULT_TEST_IMAGES = [ + 'html/sdnext-robot-2k.jpg', # SD.Next robot mascot + 'venv/lib/python3.13/site-packages/gradio/test_data/lion.jpg', + 'venv/lib/python3.13/site-packages/gradio/test_data/cheetah1.jpg', + 'venv/lib/python3.13/site-packages/skimage/data/astronaut.png', + 'venv/lib/python3.13/site-packages/skimage/data/coffee.png', +] + + +def find_test_image(): + """Find a suitable test image from defaults.""" + for img_path in DEFAULT_TEST_IMAGES: + full_path = os.path.join(script_dir, img_path) + if os.path.exists(full_path): + return full_path + return None + + +def create_test_image(): + """Create a simple test image as fallback.""" + from PIL import Image, ImageDraw + img = Image.new('RGB', (512, 512), color=(200, 150, 100)) + draw = ImageDraw.Draw(img) + draw.ellipse([100, 100, 400, 400], fill=(255, 200, 150), outline=(100, 50, 0)) + draw.rectangle([150, 200, 350, 350], fill=(150, 100, 200)) + return img + + +class TaggerTest: + """Test harness for tagger settings.""" + + def __init__(self): + self.results = {'passed': [], 'failed': [], 'skipped': []} + self.test_image = None + self.waifudiffusion_loaded = False + self.deepbooru_loaded = False + + def log_pass(self, msg): + print(f" [PASS] {msg}") + self.results['passed'].append(msg) + + def log_fail(self, msg): + print(f" [FAIL] {msg}") + self.results['failed'].append(msg) + + def log_skip(self, msg): + print(f" [SKIP] {msg}") + self.results['skipped'].append(msg) + + def log_warn(self, msg): + print(f" [WARN] {msg}") + self.results['skipped'].append(msg) + + def setup(self): + """Load test image and models.""" + from PIL import Image + + print("=" * 70) + print("TAGGER SETTINGS TEST SUITE") + print("=" * 70) + + # Get or create test image + if len(sys.argv) > 1 and os.path.exists(sys.argv[1]): + img_path = sys.argv[1] + print(f"\nUsing provided image: {img_path}") + self.test_image = Image.open(img_path).convert('RGB') + else: + img_path = find_test_image() + if img_path: + print(f"\nUsing default test image: {img_path}") + self.test_image = Image.open(img_path).convert('RGB') + else: + print("\nNo test image found, creating synthetic image...") + self.test_image = create_test_image() + + print(f"Image size: {self.test_image.size}") + + # Load models + print("\nLoading models...") + from modules.interrogate import waifudiffusion, deepbooru + + t0 = time.time() + self.waifudiffusion_loaded = waifudiffusion.load_model() + print(f" WaifuDiffusion: {'loaded' if self.waifudiffusion_loaded else 'FAILED'} ({time.time()-t0:.1f}s)") + + t0 = time.time() + self.deepbooru_loaded = deepbooru.load_model() + print(f" DeepBooru: {'loaded' if self.deepbooru_loaded else 'FAILED'} ({time.time()-t0:.1f}s)") + + def cleanup(self): + """Unload models and free memory.""" + print("\n" + "=" * 70) + print("CLEANUP") + print("=" * 70) + + from modules.interrogate import waifudiffusion, deepbooru + from modules import devices + + waifudiffusion.unload_model() + deepbooru.unload_model() + devices.torch_gc(force=True) + print(" Models unloaded") + + def print_summary(self): + """Print test summary.""" + print("\n" + "=" * 70) + print("TEST SUMMARY") + print("=" * 70) + + print(f"\n PASSED: {len(self.results['passed'])}") + for item in self.results['passed']: + print(f" - {item}") + + print(f"\n FAILED: {len(self.results['failed'])}") + for item in self.results['failed']: + print(f" - {item}") + + print(f"\n SKIPPED: {len(self.results['skipped'])}") + for item in self.results['skipped']: + print(f" - {item}") + + total = len(self.results['passed']) + len(self.results['failed']) + if total > 0: + success_rate = len(self.results['passed']) / total * 100 + print(f"\n SUCCESS RATE: {success_rate:.1f}% ({len(self.results['passed'])}/{total})") + + print("\n" + "=" * 70) + + # ========================================================================= + # TEST: ONNX Providers Detection + # ========================================================================= + def test_onnx_providers(self): + """Verify ONNX runtime providers are properly detected.""" + print("\n" + "=" * 70) + print("TEST: ONNX Providers Detection") + print("=" * 70) + + from modules import devices + + # Test 1: onnxruntime can be imported + try: + import onnxruntime as ort + self.log_pass(f"onnxruntime imported: version={ort.__version__}") + except ImportError as e: + self.log_fail(f"onnxruntime import failed: {e}") + return + + # Test 2: Get available providers + available = ort.get_available_providers() + if available and len(available) > 0: + self.log_pass(f"Available providers: {available}") + else: + self.log_fail("No ONNX providers available") + return + + # Test 3: devices.onnx is properly configured + if devices.onnx is not None and len(devices.onnx) > 0: + self.log_pass(f"devices.onnx configured: {devices.onnx}") + else: + self.log_fail(f"devices.onnx not configured: {devices.onnx}") + + # Test 4: Configured providers exist in available providers + for provider in devices.onnx: + if provider in available: + self.log_pass(f"Provider '{provider}' is available") + else: + self.log_fail(f"Provider '{provider}' configured but not available") + + # Test 5: If WaifuDiffusion loaded, check session providers + if self.waifudiffusion_loaded: + from modules.interrogate import waifudiffusion + if waifudiffusion.tagger.session is not None: + session_providers = waifudiffusion.tagger.session.get_providers() + self.log_pass(f"WaifuDiffusion session providers: {session_providers}") + else: + self.log_skip("WaifuDiffusion session not initialized") + + # ========================================================================= + # TEST: Memory Management (Offload/Reload/Unload) + # ========================================================================= + def get_memory_stats(self): + """Get current GPU and CPU memory usage.""" + import torch + + stats = {} + + # GPU memory (if CUDA available) + if torch.cuda.is_available(): + torch.cuda.synchronize() + stats['gpu_allocated'] = torch.cuda.memory_allocated() / 1024 / 1024 # MB + stats['gpu_reserved'] = torch.cuda.memory_reserved() / 1024 / 1024 # MB + else: + stats['gpu_allocated'] = 0 + stats['gpu_reserved'] = 0 + + # CPU/RAM memory (try psutil, fallback to basic) + try: + import psutil + process = psutil.Process() + stats['ram_used'] = process.memory_info().rss / 1024 / 1024 # MB + except ImportError: + stats['ram_used'] = 0 + + return stats + + def test_memory_management(self): + """Test model offload to RAM, reload to GPU, and unload with memory monitoring.""" + print("\n" + "=" * 70) + print("TEST: Memory Management (Offload/Reload/Unload)") + print("=" * 70) + + import torch + import gc + from modules import devices + from modules.interrogate import waifudiffusion, deepbooru + + # Memory leak tolerance (MB) - some variance is expected + GPU_LEAK_TOLERANCE_MB = 50 + RAM_LEAK_TOLERANCE_MB = 200 + + # ===================================================================== + # DeepBooru: Test GPU/CPU movement with memory monitoring + # ===================================================================== + if self.deepbooru_loaded: + print("\n DeepBooru Memory Management:") + + # Baseline memory before any operations + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + baseline = self.get_memory_stats() + print(f" Baseline: GPU={baseline['gpu_allocated']:.1f}MB, RAM={baseline['ram_used']:.1f}MB") + + # Test 1: Check initial state (should be on CPU after load) + initial_device = next(deepbooru.model.model.parameters()).device + print(f" Initial device: {initial_device}") + if initial_device.type == 'cpu': + self.log_pass("DeepBooru: initial state on CPU") + else: + self.log_pass(f"DeepBooru: initial state on {initial_device}") + + # Test 2: Move to GPU (start) + deepbooru.model.start() + gpu_device = next(deepbooru.model.model.parameters()).device + after_gpu = self.get_memory_stats() + print(f" After start(): {gpu_device} | GPU={after_gpu['gpu_allocated']:.1f}MB (+{after_gpu['gpu_allocated']-baseline['gpu_allocated']:.1f}MB)") + if gpu_device.type == devices.device.type: + self.log_pass(f"DeepBooru: moved to GPU ({gpu_device})") + else: + self.log_fail(f"DeepBooru: failed to move to GPU, got {gpu_device}") + + # Test 3: Run inference while on GPU + try: + tags = deepbooru.model.tag_multi(self.test_image, max_tags=3) + after_infer = self.get_memory_stats() + print(f" After inference: GPU={after_infer['gpu_allocated']:.1f}MB") + if tags: + self.log_pass(f"DeepBooru: inference on GPU works ({tags[:30]}...)") + else: + self.log_fail("DeepBooru: inference on GPU returned empty") + except Exception as e: + self.log_fail(f"DeepBooru: inference on GPU failed: {e}") + + # Test 4: Offload to CPU (stop) + deepbooru.model.stop() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + after_offload = self.get_memory_stats() + cpu_device = next(deepbooru.model.model.parameters()).device + print(f" After stop(): {cpu_device} | GPU={after_offload['gpu_allocated']:.1f}MB, RAM={after_offload['ram_used']:.1f}MB") + if cpu_device.type == 'cpu': + self.log_pass("DeepBooru: offloaded to CPU") + else: + self.log_fail(f"DeepBooru: failed to offload, still on {cpu_device}") + + # Check GPU memory returned to near baseline after offload + gpu_diff = after_offload['gpu_allocated'] - baseline['gpu_allocated'] + if gpu_diff <= GPU_LEAK_TOLERANCE_MB: + self.log_pass(f"DeepBooru: GPU memory cleared after offload (diff={gpu_diff:.1f}MB)") + else: + self.log_fail(f"DeepBooru: GPU memory leak after offload (diff={gpu_diff:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + + # Test 5: Full cycle - reload and run again + deepbooru.model.start() + try: + tags = deepbooru.model.tag_multi(self.test_image, max_tags=3) + if tags: + self.log_pass("DeepBooru: reload cycle works") + else: + self.log_fail("DeepBooru: reload cycle returned empty") + except Exception as e: + self.log_fail(f"DeepBooru: reload cycle failed: {e}") + deepbooru.model.stop() + + # Test 6: Full unload with memory check + deepbooru.unload_model() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + after_unload = self.get_memory_stats() + print(f" After unload: GPU={after_unload['gpu_allocated']:.1f}MB, RAM={after_unload['ram_used']:.1f}MB") + + if deepbooru.model.model is None: + self.log_pass("DeepBooru: unload successful") + else: + self.log_fail("DeepBooru: unload failed, model still exists") + + # Check for memory leaks after full unload + gpu_leak = after_unload['gpu_allocated'] - baseline['gpu_allocated'] + ram_leak = after_unload['ram_used'] - baseline['ram_used'] + if gpu_leak <= GPU_LEAK_TOLERANCE_MB: + self.log_pass(f"DeepBooru: no GPU memory leak after unload (diff={gpu_leak:.1f}MB)") + else: + self.log_fail(f"DeepBooru: GPU memory leak detected (diff={gpu_leak:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + + if ram_leak <= RAM_LEAK_TOLERANCE_MB: + self.log_pass(f"DeepBooru: no RAM leak after unload (diff={ram_leak:.1f}MB)") + else: + self.log_warn(f"DeepBooru: RAM increased after unload (diff={ram_leak:.1f}MB) - may be caching") + + # Reload for remaining tests + deepbooru.load_model() + + # ===================================================================== + # WaifuDiffusion: Test session lifecycle with memory monitoring + # ===================================================================== + if self.waifudiffusion_loaded: + print("\n WaifuDiffusion Memory Management:") + + # Baseline memory + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + baseline = self.get_memory_stats() + print(f" Baseline: GPU={baseline['gpu_allocated']:.1f}MB, RAM={baseline['ram_used']:.1f}MB") + + # Test 1: Session exists + if waifudiffusion.tagger.session is not None: + self.log_pass("WaifuDiffusion: session loaded") + else: + self.log_fail("WaifuDiffusion: session not loaded") + return + + # Test 2: Get current providers + providers = waifudiffusion.tagger.session.get_providers() + print(f" Active providers: {providers}") + self.log_pass(f"WaifuDiffusion: using providers {providers}") + + # Test 3: Run inference + try: + tags = waifudiffusion.tagger.predict(self.test_image, max_tags=3) + after_infer = self.get_memory_stats() + print(f" After inference: GPU={after_infer['gpu_allocated']:.1f}MB, RAM={after_infer['ram_used']:.1f}MB") + if tags: + self.log_pass(f"WaifuDiffusion: inference works ({tags[:30]}...)") + else: + self.log_fail("WaifuDiffusion: inference returned empty") + except Exception as e: + self.log_fail(f"WaifuDiffusion: inference failed: {e}") + + # Test 4: Unload session with memory check + model_name = waifudiffusion.tagger.model_name + waifudiffusion.unload_model() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + after_unload = self.get_memory_stats() + print(f" After unload: GPU={after_unload['gpu_allocated']:.1f}MB, RAM={after_unload['ram_used']:.1f}MB") + + if waifudiffusion.tagger.session is None: + self.log_pass("WaifuDiffusion: unload successful") + else: + self.log_fail("WaifuDiffusion: unload failed, session still exists") + + # Check for memory leaks after unload + gpu_leak = after_unload['gpu_allocated'] - baseline['gpu_allocated'] + ram_leak = after_unload['ram_used'] - baseline['ram_used'] + if gpu_leak <= GPU_LEAK_TOLERANCE_MB: + self.log_pass(f"WaifuDiffusion: no GPU memory leak after unload (diff={gpu_leak:.1f}MB)") + else: + self.log_fail(f"WaifuDiffusion: GPU memory leak detected (diff={gpu_leak:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + + if ram_leak <= RAM_LEAK_TOLERANCE_MB: + self.log_pass(f"WaifuDiffusion: no RAM leak after unload (diff={ram_leak:.1f}MB)") + else: + self.log_warn(f"WaifuDiffusion: RAM increased after unload (diff={ram_leak:.1f}MB) - may be caching") + + # Test 5: Reload session + waifudiffusion.load_model(model_name) + after_reload = self.get_memory_stats() + print(f" After reload: GPU={after_reload['gpu_allocated']:.1f}MB, RAM={after_reload['ram_used']:.1f}MB") + if waifudiffusion.tagger.session is not None: + self.log_pass("WaifuDiffusion: reload successful") + else: + self.log_fail("WaifuDiffusion: reload failed") + + # Test 6: Inference after reload + try: + tags = waifudiffusion.tagger.predict(self.test_image, max_tags=3) + if tags: + self.log_pass("WaifuDiffusion: inference after reload works") + else: + self.log_fail("WaifuDiffusion: inference after reload returned empty") + except Exception as e: + self.log_fail(f"WaifuDiffusion: inference after reload failed: {e}") + + # Final memory check after full cycle + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + final = self.get_memory_stats() + print(f" Final (after full cycle): GPU={final['gpu_allocated']:.1f}MB, RAM={final['ram_used']:.1f}MB") + + # ========================================================================= + # TEST: Settings Existence + # ========================================================================= + def test_settings_exist(self): + """Verify all tagger settings exist in shared.opts.""" + print("\n" + "=" * 70) + print("TEST: Settings Existence") + print("=" * 70) + + from modules import shared + + settings = [ + ('tagger_threshold', float), + ('tagger_include_rating', bool), + ('tagger_max_tags', int), + ('tagger_sort_alpha', bool), + ('tagger_use_spaces', bool), + ('tagger_escape_brackets', bool), + ('tagger_exclude_tags', str), + ('tagger_show_scores', bool), + ('waifudiffusion_model', str), + ('waifudiffusion_character_threshold', float), + ('interrogate_offload', bool), + ] + + for setting, _expected_type in settings: + if hasattr(shared.opts, setting): + value = getattr(shared.opts, setting) + self.log_pass(f"{setting} = {value!r}") + else: + self.log_fail(f"{setting} - NOT FOUND") + + # ========================================================================= + # TEST: Parameter Effect - Tests a single parameter on both taggers + # ========================================================================= + def test_parameter(self, param_name, test_func, waifudiffusion_supported=True, deepbooru_supported=True): + """Test a parameter on both WaifuDiffusion and DeepBooru.""" + print(f"\n Testing: {param_name}") + + if waifudiffusion_supported and self.waifudiffusion_loaded: + try: + result = test_func('waifudiffusion') + if result is True: + self.log_pass(f"WaifuDiffusion: {param_name}") + elif result is False: + self.log_fail(f"WaifuDiffusion: {param_name}") + else: + self.log_skip(f"WaifuDiffusion: {param_name} - {result}") + except Exception as e: + self.log_fail(f"WaifuDiffusion: {param_name} - {e}") + elif waifudiffusion_supported: + self.log_skip(f"WaifuDiffusion: {param_name} - model not loaded") + + if deepbooru_supported and self.deepbooru_loaded: + try: + result = test_func('deepbooru') + if result is True: + self.log_pass(f"DeepBooru: {param_name}") + elif result is False: + self.log_fail(f"DeepBooru: {param_name}") + else: + self.log_skip(f"DeepBooru: {param_name} - {result}") + except Exception as e: + self.log_fail(f"DeepBooru: {param_name} - {e}") + elif deepbooru_supported: + self.log_skip(f"DeepBooru: {param_name} - model not loaded") + + def tag(self, tagger, **kwargs): + """Helper to call the appropriate tagger.""" + if tagger == 'waifudiffusion': + from modules.interrogate import waifudiffusion + return waifudiffusion.tagger.predict(self.test_image, **kwargs) + else: + from modules.interrogate import deepbooru + return deepbooru.model.tag(self.test_image, **kwargs) + + # ========================================================================= + # TEST: general_threshold + # ========================================================================= + def test_threshold(self): + """Test that threshold affects tag count.""" + print("\n" + "=" * 70) + print("TEST: general_threshold effect") + print("=" * 70) + + def check_threshold(tagger): + tags_high = self.tag(tagger, general_threshold=0.9) + tags_low = self.tag(tagger, general_threshold=0.1) + + count_high = len(tags_high.split(', ')) if tags_high else 0 + count_low = len(tags_low.split(', ')) if tags_low else 0 + + print(f" {tagger}: threshold=0.9 -> {count_high} tags, threshold=0.1 -> {count_low} tags") + + if count_low > count_high: + return True + elif count_low == count_high == 0: + return "no tags returned" + else: + return "threshold effect unclear" + + self.test_parameter('general_threshold', check_threshold) + + # ========================================================================= + # TEST: max_tags + # ========================================================================= + def test_max_tags(self): + """Test that max_tags limits output.""" + print("\n" + "=" * 70) + print("TEST: max_tags effect") + print("=" * 70) + + def check_max_tags(tagger): + tags_5 = self.tag(tagger, general_threshold=0.1, max_tags=5) + tags_50 = self.tag(tagger, general_threshold=0.1, max_tags=50) + + count_5 = len(tags_5.split(', ')) if tags_5 else 0 + count_50 = len(tags_50.split(', ')) if tags_50 else 0 + + print(f" {tagger}: max_tags=5 -> {count_5} tags, max_tags=50 -> {count_50} tags") + + return count_5 <= 5 + + self.test_parameter('max_tags', check_max_tags) + + # ========================================================================= + # TEST: use_spaces + # ========================================================================= + def test_use_spaces(self): + """Test that use_spaces converts underscores to spaces.""" + print("\n" + "=" * 70) + print("TEST: use_spaces effect") + print("=" * 70) + + def check_use_spaces(tagger): + tags_under = self.tag(tagger, use_spaces=False, max_tags=10) + tags_space = self.tag(tagger, use_spaces=True, max_tags=10) + + print(f" {tagger} use_spaces=False: {tags_under[:50]}...") + print(f" {tagger} use_spaces=True: {tags_space[:50]}...") + + # Check if underscores are converted to spaces + has_underscore_before = '_' in tags_under + has_underscore_after = '_' in tags_space.replace(', ', ',') # ignore comma-space + + # If there were underscores before but not after, it worked + if has_underscore_before and not has_underscore_after: + return True + # If there were never underscores, inconclusive + elif not has_underscore_before: + return "no underscores in tags to convert" + else: + return False + + self.test_parameter('use_spaces', check_use_spaces) + + # ========================================================================= + # TEST: escape_brackets + # ========================================================================= + def test_escape_brackets(self): + """Test that escape_brackets escapes special characters.""" + print("\n" + "=" * 70) + print("TEST: escape_brackets effect") + print("=" * 70) + + def check_escape_brackets(tagger): + tags_escaped = self.tag(tagger, escape_brackets=True, max_tags=30, general_threshold=0.1) + tags_raw = self.tag(tagger, escape_brackets=False, max_tags=30, general_threshold=0.1) + + print(f" {tagger} escape=True: {tags_escaped[:60]}...") + print(f" {tagger} escape=False: {tags_raw[:60]}...") + + # Check for escaped brackets (\\( or \\)) + has_escaped = '\\(' in tags_escaped or '\\)' in tags_escaped + has_unescaped = '(' in tags_raw.replace('\\(', '') or ')' in tags_raw.replace('\\)', '') + + if has_escaped: + return True + elif has_unescaped: + # Has brackets but not escaped - fail + return False + else: + return "no brackets in tags to escape" + + self.test_parameter('escape_brackets', check_escape_brackets) + + # ========================================================================= + # TEST: sort_alpha + # ========================================================================= + def test_sort_alpha(self): + """Test that sort_alpha sorts tags alphabetically.""" + print("\n" + "=" * 70) + print("TEST: sort_alpha effect") + print("=" * 70) + + def check_sort_alpha(tagger): + tags_conf = self.tag(tagger, sort_alpha=False, max_tags=20, general_threshold=0.1) + tags_alpha = self.tag(tagger, sort_alpha=True, max_tags=20, general_threshold=0.1) + + list_conf = [t.strip() for t in tags_conf.split(',')] + list_alpha = [t.strip() for t in tags_alpha.split(',')] + + print(f" {tagger} by_confidence: {', '.join(list_conf[:5])}...") + print(f" {tagger} alphabetical: {', '.join(list_alpha[:5])}...") + + is_sorted = list_alpha == sorted(list_alpha) + return is_sorted + + self.test_parameter('sort_alpha', check_sort_alpha) + + # ========================================================================= + # TEST: exclude_tags + # ========================================================================= + def test_exclude_tags(self): + """Test that exclude_tags removes specified tags.""" + print("\n" + "=" * 70) + print("TEST: exclude_tags effect") + print("=" * 70) + + def check_exclude_tags(tagger): + tags_all = self.tag(tagger, max_tags=50, general_threshold=0.1, exclude_tags='') + tag_list = [t.strip().replace(' ', '_') for t in tags_all.split(',')] + + if len(tag_list) < 2: + return "not enough tags to test" + + # Exclude the first tag + tag_to_exclude = tag_list[0] + tags_filtered = self.tag(tagger, max_tags=50, general_threshold=0.1, exclude_tags=tag_to_exclude) + + print(f" {tagger} without exclusion: {tags_all[:50]}...") + print(f" {tagger} excluding '{tag_to_exclude}': {tags_filtered[:50]}...") + + # Check if the exact tag was removed by parsing the filtered list + filtered_list = [t.strip().replace(' ', '_') for t in tags_filtered.split(',')] + # Also check space variant + tag_space_variant = tag_to_exclude.replace('_', ' ') + tag_present = tag_to_exclude in filtered_list or tag_space_variant in [t.strip() for t in tags_filtered.split(',')] + return not tag_present + + self.test_parameter('exclude_tags', check_exclude_tags) + + # ========================================================================= + # TEST: tagger_show_scores (via shared.opts) + # ========================================================================= + def test_show_scores(self): + """Test that tagger_show_scores adds confidence scores.""" + print("\n" + "=" * 70) + print("TEST: tagger_show_scores effect") + print("=" * 70) + + from modules import shared + + def check_show_scores(tagger): + original = shared.opts.tagger_show_scores + + shared.opts.tagger_show_scores = False + tags_no_scores = self.tag(tagger, max_tags=5) + + shared.opts.tagger_show_scores = True + tags_with_scores = self.tag(tagger, max_tags=5) + + shared.opts.tagger_show_scores = original + + print(f" {tagger} show_scores=False: {tags_no_scores[:50]}...") + print(f" {tagger} show_scores=True: {tags_with_scores[:50]}...") + + has_scores = ':' in tags_with_scores and '(' in tags_with_scores + no_scores = ':' not in tags_no_scores + + return has_scores and no_scores + + self.test_parameter('tagger_show_scores', check_show_scores) + + # ========================================================================= + # TEST: include_rating + # ========================================================================= + def test_include_rating(self): + """Test that include_rating includes/excludes rating tags.""" + print("\n" + "=" * 70) + print("TEST: include_rating effect") + print("=" * 70) + + def check_include_rating(tagger): + tags_no_rating = self.tag(tagger, include_rating=False, max_tags=100, general_threshold=0.01) + tags_with_rating = self.tag(tagger, include_rating=True, max_tags=100, general_threshold=0.01) + + print(f" {tagger} include_rating=False: {tags_no_rating[:60]}...") + print(f" {tagger} include_rating=True: {tags_with_rating[:60]}...") + + # Rating tags typically start with "rating:" or are like "safe", "questionable", "explicit" + rating_keywords = ['rating:', 'safe', 'questionable', 'explicit', 'general', 'sensitive'] + + has_rating_before = any(kw in tags_no_rating.lower() for kw in rating_keywords) + has_rating_after = any(kw in tags_with_rating.lower() for kw in rating_keywords) + + if has_rating_after and not has_rating_before: + return True + elif has_rating_after and has_rating_before: + return "rating tags appear in both (may need very low threshold)" + elif not has_rating_after: + return "no rating tags detected" + else: + return False + + self.test_parameter('include_rating', check_include_rating) + + # ========================================================================= + # TEST: character_threshold (WaifuDiffusion only) + # ========================================================================= + def test_character_threshold(self): + """Test that character_threshold affects character tag count (WaifuDiffusion only).""" + print("\n" + "=" * 70) + print("TEST: character_threshold effect (WaifuDiffusion only)") + print("=" * 70) + + def check_character_threshold(tagger): + if tagger != 'waifudiffusion': + return "not supported" + + # Character threshold only affects character tags + # We need an image with character tags to properly test this + tags_high = self.tag(tagger, character_threshold=0.99, general_threshold=0.5) + tags_low = self.tag(tagger, character_threshold=0.1, general_threshold=0.5) + + print(f" {tagger} char_threshold=0.99: {tags_high[:50]}...") + print(f" {tagger} char_threshold=0.10: {tags_low[:50]}...") + + # If thresholds are different, the setting is at least being applied + # Hard to verify without an image with known character tags + return True # Setting exists and is applied (verified by code inspection) + + self.test_parameter('character_threshold', check_character_threshold, deepbooru_supported=False) + + # ========================================================================= + # TEST: Unified Interface + # ========================================================================= + def test_unified_interface(self): + """Test that the unified tagger interface works for both backends.""" + print("\n" + "=" * 70) + print("TEST: Unified tagger.tag() interface") + print("=" * 70) + + from modules.interrogate import tagger + + # Test WaifuDiffusion through unified interface + if self.waifudiffusion_loaded: + try: + models = tagger.get_models() + waifudiffusion_model = next((m for m in models if m != 'DeepBooru'), None) + if waifudiffusion_model: + tags = tagger.tag(self.test_image, model_name=waifudiffusion_model, max_tags=5) + print(f" WaifuDiffusion ({waifudiffusion_model}): {tags[:50]}...") + self.log_pass("Unified interface: WaifuDiffusion") + except Exception as e: + self.log_fail(f"Unified interface: WaifuDiffusion - {e}") + + # Test DeepBooru through unified interface + if self.deepbooru_loaded: + try: + tags = tagger.tag(self.test_image, model_name='DeepBooru', max_tags=5) + print(f" DeepBooru: {tags[:50]}...") + self.log_pass("Unified interface: DeepBooru") + except Exception as e: + self.log_fail(f"Unified interface: DeepBooru - {e}") + + def run_all_tests(self): + """Run all tests.""" + self.setup() + + self.test_onnx_providers() + self.test_memory_management() + self.test_settings_exist() + self.test_threshold() + self.test_max_tags() + self.test_use_spaces() + self.test_escape_brackets() + self.test_sort_alpha() + self.test_exclude_tags() + self.test_show_scores() + self.test_include_rating() + self.test_character_threshold() + self.test_unified_interface() + + self.cleanup() + self.print_summary() + + return len(self.results['failed']) == 0 + + +if __name__ == "__main__": + test = TaggerTest() + success = test.run_all_tests() + sys.exit(0 if success else 1) diff --git a/html/previews.json b/data/previews.json similarity index 100% rename from html/previews.json rename to data/previews.json diff --git a/html/reference-cloud.json b/data/reference-cloud.json similarity index 100% rename from html/reference-cloud.json rename to data/reference-cloud.json diff --git a/html/reference-community.json b/data/reference-community.json similarity index 89% rename from html/reference-community.json rename to data/reference-community.json index b76aab420..bc9642c60 100644 --- a/html/reference-community.json +++ b/data/reference-community.json @@ -128,5 +128,12 @@ "preview": "shuttleai--shuttle-jaguar.jpg", "tags": "community", "skip": true + }, + "Anima": { + "path": "CalamitousFelicitousness/Anima-sdnext-diffusers", + "preview": "CalamitousFelicitousness--Anima-sdnext-diffusers.png", + "desc": "Modified Cosmos-Predict-2B that replaces the T5-11B text encoder with Qwen3-0.6B. Anima is a 2 billion parameter text-to-image model created via a collaboration between CircleStone Labs and Comfy Org. It is focused mainly on anime concepts, characters, and styles, but is also capable of generating a wide variety of other non-photorealistic content. The model is designed for making illustrations and artistic images, and will not work well at realism.", + "tags": "community", + "skip": true } } diff --git a/html/reference-distilled.json b/data/reference-distilled.json similarity index 100% rename from html/reference-distilled.json rename to data/reference-distilled.json diff --git a/html/reference-quant.json b/data/reference-quant.json similarity index 100% rename from html/reference-quant.json rename to data/reference-quant.json diff --git a/html/reference.json b/data/reference.json similarity index 98% rename from html/reference.json rename to data/reference.json index 2f1f6562b..d2ea919c7 100644 --- a/html/reference.json +++ b/data/reference.json @@ -143,6 +143,15 @@ "date": "2025 January" }, + "Z-Image": { + "path": "Tongyi-MAI/Z-Image", + "preview": "Tongyi-MAI--Z-Image.jpg", + "desc": "Z-Image, an efficient image generation foundation model built on a Single-Stream Diffusion Transformer architecture. It preserves the complete training signal with full CFG support, enabling aesthetic versatility from hyper-realistic photography to anime, enhanced output diversity, and robust negative prompting for artifact suppression. Ideal base for LoRA training, ControlNet, and semantic conditioning.", + "skip": true, + "extras": "sampler: Default, cfg_scale: 4.0, steps: 50", + "size": 20.3, + "date": "2026 January" + }, "Z-Image-Turbo": { "path": "Tongyi-MAI/Z-Image-Turbo", "preview": "Tongyi-MAI--Z-Image-Turbo.jpg", diff --git a/html/upscalers.json b/data/upscalers.json similarity index 100% rename from html/upscalers.json rename to data/upscalers.json diff --git a/eslint.config.mjs b/eslint.config.mjs index fddda6ca1..13b247a3f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,6 +53,7 @@ const jsConfig = defineConfig([ generateForever: 'readonly', showContributors: 'readonly', opts: 'writable', + monitorOption: 'readonly', sortUIElements: 'readonly', all_gallery_buttons: 'readonly', selected_gallery_button: 'readonly', @@ -98,6 +99,8 @@ const jsConfig = defineConfig([ idbAdd: 'readonly', idbCount: 'readonly', idbFolderCleanup: 'readonly', + idbClearAll: 'readonly', + idbIsReady: 'readonly', initChangelog: 'readonly', sendNotification: 'readonly', monitorConnection: 'readonly', @@ -241,6 +244,9 @@ const jsonConfig = defineConfig([ plugins: { json }, language: 'json/json', extends: ['json/recommended'], + rules: { + 'json/no-empty-keys': 'off', + }, }, ]); diff --git a/html/locale_en.json b/html/locale_en.json index 0894db703..5eb0b9f89 100644 --- a/html/locale_en.json +++ b/html/locale_en.json @@ -90,7 +90,7 @@ {"id":"","label":"Embedding","localized":"","reload":"","hint":"Textual inversion embedding is a trained embedded information about the subject"}, {"id":"","label":"Hypernetwork","localized":"","reload":"","hint":"Small trained neural network that modifies behavior of the loaded model"}, {"id":"","label":"VLM Caption","localized":"","reload":"","hint":"Analyze image using vision langugage model"}, - {"id":"","label":"CLiP Interrogate","localized":"","reload":"","hint":"Analyze image using CLiP model"}, + {"id":"","label":"OpenCLiP","localized":"","reload":"","hint":"Analyze image using CLiP model via OpenCLiP"}, {"id":"","label":"VAE","localized":"","reload":"","hint":"Variational Auto Encoder: model used to run image decode at the end of generate"}, {"id":"","label":"History","localized":"","reload":"","hint":"List of previous generations that can be further reprocessed"}, {"id":"","label":"UI disable variable aspect ratio","localized":"","reload":"","hint":"When disabled, all thumbnails appear as squared images"}, diff --git a/installer.py b/installer.py index 79f3f9b69..84790cac0 100644 --- a/installer.py +++ b/installer.py @@ -112,7 +112,7 @@ def install_traceback(suppress: list = []): width = os.environ.get("SD_TRACEWIDTH", console.width if console else None) if width is not None: width = int(width) - traceback_install( + log.excepthook = traceback_install( console=console, extra_lines=int(os.environ.get("SD_TRACELINES", 1)), max_frames=int(os.environ.get("SD_TRACEFRAMES", 16)), @@ -168,7 +168,6 @@ def setup_logging(): def get(self): return self.buffer - class LogFilter(logging.Filter): def __init__(self): super().__init__() @@ -215,6 +214,23 @@ def setup_logging(): logging.Logger.trace = partialmethod(logging.Logger.log, logging.TRACE) logging.trace = partial(logging.log, logging.TRACE) + def exception_hook(e: Exception, suppress=[]): + from rich.traceback import Traceback + tb = Traceback.from_exception(type(e), e, e.__traceback__, show_locals=False, max_frames=16, extra_lines=1, suppress=suppress, theme="ansi_dark", word_wrap=False, width=console.width) + # print-to-console, does not get printed-to-file + exc_type, exc_value, exc_traceback = sys.exc_info() + log.excepthook(exc_type, exc_value, exc_traceback) + # print-to-file, temporarily disable-console-handler + for handler in log.handlers.copy(): + if isinstance(handler, RichHandler): + log.removeHandler(handler) + with console.capture() as capture: + console.print(tb) + log.critical(capture.get()) + log.addHandler(rh) + + log.traceback = exception_hook + level = logging.DEBUG if (args.debug or args.trace) else logging.INFO log.setLevel(logging.DEBUG) # log to file is always at level debug for facility `sd` log.print = rprint @@ -240,8 +256,10 @@ def setup_logging(): ) logging.basicConfig(level=logging.ERROR, format='%(asctime)s | %(name)s | %(levelname)s | %(module)s | %(message)s', handlers=[logging.NullHandler()]) # redirect default logger to null + pretty_install(console=console) install_traceback() + while log.hasHandlers() and len(log.handlers) > 0: log.removeHandler(log.handlers[0]) @@ -288,7 +306,6 @@ def setup_logging(): logging.getLogger("torch").setLevel(logging.ERROR) logging.getLogger("ControlNet").handlers = log.handlers logging.getLogger("lycoris").handlers = log.handlers - # logging.getLogger("DeepSpeed").handlers = log.handlers ts('log', t_start) @@ -712,9 +729,9 @@ def install_cuda(): log.info('CUDA: nVidia toolkit detected') ts('cuda', t_start) if args.use_nightly: - cmd = os.environ.get('TORCH_COMMAND', '--upgrade --pre torch torchvision --index-url https://download.pytorch.org/whl/nightly/cu128 --extra-index-url https://download.pytorch.org/whl/nightly/cu126') + cmd = os.environ.get('TORCH_COMMAND', '--upgrade --pre torch torchvision --index-url https://download.pytorch.org/whl/nightly/cu128 --extra-index-url https://download.pytorch.org/whl/nightly/cu130') else: - cmd = os.environ.get('TORCH_COMMAND', 'torch==2.9.1+cu128 torchvision==0.24.1+cu128 --index-url https://download.pytorch.org/whl/cu128') + cmd = os.environ.get('TORCH_COMMAND', 'torch==2.10.0+cu128 torchvision==0.25.0+cu128 --index-url https://download.pytorch.org/whl/cu128') return cmd @@ -765,7 +782,6 @@ def install_rocm_zluda(): if sys.platform == "win32": if args.use_zluda: - #check_python(supported_minors=[10, 11, 12, 13], reason='ZLUDA backend requires a Python version between 3.10 and 3.13') torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.7.1+cu118 torchvision==0.22.1+cu118 --index-url https://download.pytorch.org/whl/cu118') if args.device_id is not None: @@ -795,6 +811,7 @@ def install_rocm_zluda(): torch_command = os.environ.get('TORCH_COMMAND', f'torch torchvision --index-url https://rocm.nightlies.amd.com/{device.therock}') else: check_python(supported_minors=[12], reason='ROCm: Windows preview python==3.12 required') + # torch 2.8.0a0 is the last version with rocm 6.4 support torch_command = os.environ.get('TORCH_COMMAND', '--no-cache-dir https://repo.radeon.com/rocm/windows/rocm-rel-6.4.4/torch-2.8.0a0%2Bgitfc14c65-cp312-cp312-win_amd64.whl https://repo.radeon.com/rocm/windows/rocm-rel-6.4.4/torchvision-0.24.0a0%2Bc85f008-cp312-cp312-win_amd64.whl') else: #check_python(supported_minors=[10, 11, 12, 13, 14], reason='ROCm backend requires a Python version between 3.10 and 3.13') @@ -804,7 +821,11 @@ def install_rocm_zluda(): else: # oldest rocm version on nightly is 7.0 torch_command = os.environ.get('TORCH_COMMAND', '--upgrade --pre torch torchvision --index-url https://download.pytorch.org/whl/nightly/rocm7.0') else: - if rocm.version is None or float(rocm.version) >= 6.4: # assume the latest if version check fails + if rocm.version is None or float(rocm.version) >= 7.1: # assume the latest if version check fails + torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.10.0+rocm7.1 torchvision==0.25.0+rocm7.1 --index-url https://download.pytorch.org/whl/rocm7.1') + elif rocm.version == "7.0": # assume the latest if version check fails + torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.10.0+rocm7.0 torchvision==0.25.0+rocm7.0 --index-url https://download.pytorch.org/whl/rocm7.0') + elif rocm.version == "6.4": torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.9.1+rocm6.4 torchvision==0.24.1+rocm6.4 --index-url https://download.pytorch.org/whl/rocm6.4') elif rocm.version == "6.3": torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.9.1+rocm6.3 torchvision==0.24.1+rocm6.3 --index-url https://download.pytorch.org/whl/rocm6.3') @@ -841,7 +862,7 @@ def install_ipex(): if args.use_nightly: torch_command = os.environ.get('TORCH_COMMAND', '--upgrade --pre torch torchvision --index-url https://download.pytorch.org/whl/nightly/xpu') else: - torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.9.1+xpu torchvision==0.24.1+xpu --index-url https://download.pytorch.org/whl/xpu') + torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.10.0+xpu torchvision==0.25.0+xpu --index-url https://download.pytorch.org/whl/xpu') ts('ipex', t_start) return torch_command @@ -854,13 +875,13 @@ def install_openvino(): #check_python(supported_minors=[10, 11, 12, 13], reason='OpenVINO backend requires a Python version between 3.10 and 3.13') if sys.platform == 'darwin': - torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.9.1 torchvision==0.24.1') + torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.10.0 torchvision==0.25.0') else: - torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.9.1+cpu torchvision==0.24.1 --index-url https://download.pytorch.org/whl/cpu') + torch_command = os.environ.get('TORCH_COMMAND', 'torch==2.10.0+cpu torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cpu') if not (args.skip_all or args.skip_requirements): - install(os.environ.get('OPENVINO_COMMAND', 'openvino==2025.3.0'), 'openvino') - install(os.environ.get('NNCF_COMMAND', 'nncf==2.18.0'), 'nncf') + install(os.environ.get('OPENVINO_COMMAND', 'openvino==2025.4.1'), 'openvino') + install(os.environ.get('NNCF_COMMAND', 'nncf==2.19.0'), 'nncf') ts('openvino', t_start) return torch_command @@ -1427,6 +1448,7 @@ def set_environment(): os.environ.setdefault('TORCH_CUDNN_V8_API_ENABLED', '1') os.environ.setdefault('TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD', '1') os.environ.setdefault('TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL', '1') + os.environ.setdefault('MIOPEN_FIND_MODE', '2') os.environ.setdefault('UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS', '1') os.environ.setdefault('USE_TORCH', '1') os.environ.setdefault('UV_INDEX_STRATEGY', 'unsafe-any-match') @@ -1540,7 +1562,7 @@ def check_ui(ver): t_start = time.time() if not same(ver): - log.debug(f'Branch mismatch: sdnext={ver["branch"]} ui={ver["ui"]}') + log.debug(f'Branch mismatch: {ver}') cwd = os.getcwd() try: os.chdir('extensions-builtin/sdnext-modernui') @@ -1548,10 +1570,7 @@ def check_ui(ver): git('checkout ' + target, ignore=True, optional=True) os.chdir(cwd) ver = get_version(force=True) - if not same(ver): - log.debug(f'Branch synchronized: {ver["branch"]}') - else: - log.debug(f'Branch sync failed: sdnext={ver["branch"]} ui={ver["ui"]}') + log.debug(f'Branch sync: {ver}') except Exception as e: log.debug(f'Branch switch: {e}') os.chdir(cwd) diff --git a/javascript/gallery.js b/javascript/gallery.js index fb81bb58a..f72ddc29f 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -2,6 +2,7 @@ let ws; let url; let currentImage = null; +let currentGalleryFolder = null; let pruneImagesTimer; let outstanding = 0; let lastSort = 0; @@ -20,6 +21,7 @@ const el = { search: undefined, status: undefined, btnSend: undefined, + clearCacheFolder: undefined, }; const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr']; @@ -117,9 +119,12 @@ function updateGalleryStyles() { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition-duration: 0.2s; + transition-property: color, opacity, background-color, border-color; + transition-timing-function: ease-out; } .gallery-folder:hover { - background-color: var(--button-primary-background-fill-hover); + background-color: var(--button-primary-background-fill-hover, var(--sd-button-hover-color)); } .gallery-folder-selected { background-color: var(--sd-button-selected-color); @@ -258,6 +263,14 @@ class SimpleFunctionQueue { this.#queue = []; } + static abortLogger(identifier, result) { + if (typeof result === 'string' || (result instanceof DOMException && result.name === 'AbortError')) { + log(identifier, result?.message || result); + } else { + error(identifier, result.message); + } + } + /** * @param {{ * signal: AbortSignal, @@ -301,6 +314,8 @@ class SimpleFunctionQueue { // HTML Elements class GalleryFolder extends HTMLElement { + static folders = new Set(); + constructor(folder) { super(); // Support both old format (string) and new format (object with path and label) @@ -314,21 +329,173 @@ class GalleryFolder extends HTMLElement { this.style.overflowX = 'hidden'; this.shadow = this.attachShadow({ mode: 'open' }); this.shadow.adoptedStyleSheets = [folderStylesheet]; + + this.div = document.createElement('div'); } connectedCallback() { - const div = document.createElement('div'); - div.className = 'gallery-folder'; - div.innerHTML = `\uf03e ${this.label}`; - div.title = this.name; // Show full path on hover - div.addEventListener('click', () => { - for (const folder of el.folders.children) { - if (folder.name === this.name) folder.shadow.firstElementChild.classList.add('gallery-folder-selected'); - else folder.shadow.firstElementChild.classList.remove('gallery-folder-selected'); + if (GalleryFolder.folders.has(this)) return; // Element is just being moved + + this.div.className = 'gallery-folder'; + this.div.innerHTML = `\uf03e ${this.label}`; + this.div.title = this.name; // Show full path on hover + this.div.addEventListener('click', () => { this.updateSelected(); }); // Ensures 'this' isn't the div in the called method + this.div.addEventListener('click', fetchFilesWS); // eslint-disable-line no-use-before-define + this.shadow.appendChild(this.div); + GalleryFolder.folders.add(this); + } + + async disconnectedCallback() { + await Promise.resolve(); // Wait for other microtasks (such as element moving) + if (this.isConnected) return; + GalleryFolder.folders.delete(this); + } + + updateSelected() { + this.div.classList.add('gallery-folder-selected'); + for (const folder of GalleryFolder.folders) { + if (folder !== this) { + folder.div.classList.remove('gallery-folder-selected'); } - }); - div.addEventListener('click', fetchFilesWS); // eslint-disable-line no-use-before-define - this.shadow.appendChild(div); + } + } +} + +async function delayFetchThumb(fn, signal) { + await awaitForOutstanding(16, signal); + try { + outstanding++; + const ts = Date.now().toString(); + const res = await authFetch(`${window.api}/browser/thumb?file=${encodeURI(fn)}&ts=${ts}`, { priority: 'low' }); + if (!res.ok) { + error(`fetchThumb: ${res.statusText}`); + return undefined; + } + const json = await res.json(); + if (!res || !json || json.error || Object.keys(json).length === 0) { + if (json.error) error(`fetchThumb: ${json.error}`); + return undefined; + } + return json; + } finally { + outstanding--; + } +} + +class GalleryFile extends HTMLElement { + /** @type {AbortSignal} */ + #signal; + + constructor(folder, file, signal) { + super(); + this.folder = folder; + this.name = file; + this.#signal = signal; + this.size = 0; + this.mtime = 0; + this.hash = undefined; + this.exif = ''; + this.width = 0; + this.height = 0; + this.src = `${this.folder}/${this.name}`; + this.shadow = this.attachShadow({ mode: 'open' }); + this.shadow.adoptedStyleSheets = [fileStylesheet]; + + this.firstRun = true; + } + + async connectedCallback() { + if (!this.firstRun) return; // Element is just being moved + this.firstRun = false; + + // Check separator state early to hide the element immediately + const dir = this.name.match(/(.*)[/\\]/); + if (dir && dir[1]) { + const dirPath = dir[1]; + const isOpen = separatorStates.get(dirPath); + if (isOpen === false) { + this.style.display = 'none'; + } + } + + // Normalize path to ensure consistent hash regardless of which folder view is used + const normalizedPath = this.src.replace(/\/+/g, '/').replace(/\/$/, ''); + this.hash = await getHash(`${normalizedPath}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define + const cachedData = (this.hash && opts.browser_cache) ? await idbGet(this.hash).catch(() => undefined) : undefined; + const img = document.createElement('img'); + img.className = 'gallery-file'; + img.loading = 'lazy'; + img.onload = async () => { + img.title += `\nResolution: ${this.width} x ${this.height}`; + this.title = img.title; + if (!cachedData && opts.browser_cache) { + if ((this.width === 0) || (this.height === 0)) { // fetch thumb failed so we use actual image + this.width = img.naturalWidth; + this.height = img.naturalHeight; + } + } + }; + let ok = true; + if (cachedData?.img) { + img.src = cachedData.img; + this.exif = cachedData.exif; + this.width = cachedData.width; + this.height = cachedData.height; + this.size = cachedData.size; + this.mtime = new Date(cachedData.mtime); + } else { + try { + const json = await delayFetchThumb(this.src, this.#signal); + if (!json) { + ok = false; + } else { + img.src = json.data; + this.exif = json.exif; + this.width = json.width; + this.height = json.height; + this.size = json.size; + this.mtime = new Date(json.mtime); + if (opts.browser_cache) { + await idbAdd({ + hash: this.hash, + folder: this.folder, + file: this.name, + size: this.size, + mtime: this.mtime, + width: this.width, + height: this.height, + src: this.src, + exif: this.exif, + img: img.src, + // exif: await getExif(img), // alternative client-side exif + // img: await createThumb(img), // alternative client-side thumb + }); + } + } + } catch (err) { // thumb fetch failed so assign actual image + img.src = `file=${this.src}`; + } + } + if (this.#signal.aborted) { // Do not change the operations order from here... + return; + } + galleryHashes.add(this.hash); + if (!ok) { + return; + } // ... to here unless modifications are also being made to maintenance functionality and the usage of AbortController/AbortSignal + img.onclick = () => { + setGallerySelectionByElement(this, { send: true }); + }; + img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`; + this.title = img.title; + + // Final visibility check based on search term. + const shouldDisplayBasedOnSearch = this.title.toLowerCase().includes(el.search.value.toLowerCase()); + if (this.style.display !== 'none') { // Only proceed if not already hidden by a closed separator + this.style.display = shouldDisplayBasedOnSearch ? 'unset' : 'none'; + } + + this.shadow.appendChild(img); } } @@ -459,148 +626,6 @@ async function addSeparators() { } } -async function delayFetchThumb(fn, signal) { - await awaitForOutstanding(16, signal); - try { - outstanding++; - const ts = Date.now().toString(); - const res = await authFetch(`${window.api}/browser/thumb?file=${encodeURI(fn)}&ts=${ts}`, { priority: 'low' }); - if (!res.ok) { - error(`fetchThumb: ${res.statusText}`); - return undefined; - } - const json = await res.json(); - if (!res || !json || json.error || Object.keys(json).length === 0) { - if (json.error) error(`fetchThumb: ${json.error}`); - return undefined; - } - return json; - } finally { - outstanding--; - } -} - -class GalleryFile extends HTMLElement { - /** @type {AbortSignal} */ - #signal; - - constructor(folder, file, signal) { - super(); - this.folder = folder; - this.name = file; - this.#signal = signal; - this.size = 0; - this.mtime = 0; - this.hash = undefined; - this.exif = ''; - this.width = 0; - this.height = 0; - this.src = `${this.folder}/${this.name}`; - this.shadow = this.attachShadow({ mode: 'open' }); - this.shadow.adoptedStyleSheets = [fileStylesheet]; - } - - async connectedCallback() { - if (this.shadow.children.length > 0) { - return; - } - - // Check separator state early to hide the element immediately - const dir = this.name.match(/(.*)[/\\]/); - if (dir && dir[1]) { - const dirPath = dir[1]; - const isOpen = separatorStates.get(dirPath); - if (isOpen === false) { - this.style.display = 'none'; - } - } - - // Normalize path to ensure consistent hash regardless of which folder view is used - const normalizedPath = this.src.replace(/\/+/g, '/').replace(/\/$/, ''); - this.hash = await getHash(`${normalizedPath}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define - const cachedData = (this.hash && opts.browser_cache) ? await idbGet(this.hash).catch(() => undefined) : undefined; - const img = document.createElement('img'); - img.className = 'gallery-file'; - img.loading = 'lazy'; - img.onload = async () => { - img.title += `\nResolution: ${this.width} x ${this.height}`; - this.title = img.title; - if (!cachedData && opts.browser_cache) { - if ((this.width === 0) || (this.height === 0)) { // fetch thumb failed so we use actual image - this.width = img.naturalWidth; - this.height = img.naturalHeight; - } - } - }; - let ok = true; - if (cachedData?.img) { - img.src = cachedData.img; - this.exif = cachedData.exif; - this.width = cachedData.width; - this.height = cachedData.height; - this.size = cachedData.size; - this.mtime = new Date(cachedData.mtime); - } else { - try { - const json = await delayFetchThumb(this.src, this.#signal); - if (!json) { - ok = false; - } else { - img.src = json.data; - this.exif = json.exif; - this.width = json.width; - this.height = json.height; - this.size = json.size; - this.mtime = new Date(json.mtime); - if (opts.browser_cache) { - // Store file's actual parent directory (not browsed folder) for consistent cleanup - const fileDir = this.src.replace(/\/+/g, '/').replace(/\/[^/]+$/, ''); - await idbAdd({ - hash: this.hash, - folder: fileDir, - file: this.name, - size: this.size, - mtime: this.mtime, - width: this.width, - height: this.height, - src: this.src, - exif: this.exif, - img: img.src, - // exif: await getExif(img), // alternative client-side exif - // img: await createThumb(img), // alternative client-side thumb - }); - } - } - } catch (err) { // thumb fetch failed so assign actual image - img.src = `file=${this.src}`; - } - } - if (this.#signal.aborted) { // Do not change the operations order from here... - return; - } - galleryHashes.add(this.hash); - if (!ok) { - return; - } // ... to here unless modifications are also being made to maintenance functionality and the usage of AbortController/AbortSignal - img.onclick = () => { - setGallerySelectionByElement(this, { send: true }); - }; - img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`; - if (this.shadow.children.length > 0) { - return; // avoid double-adding - } - this.title = img.title; - - // Final visibility check based on search term. - const shouldDisplayBasedOnSearch = this.title.toLowerCase().includes(el.search.value.toLowerCase()); - if (this.style.display !== 'none') { // Only proceed if not already hidden by a closed separator - this.style.display = shouldDisplayBasedOnSearch ? 'unset' : 'none'; - } - - this.shadow.appendChild(img); - } -} - // methods const gallerySendImage = (_images) => [currentImage]; // invoked by gradio button @@ -919,9 +944,10 @@ async function gallerySort(btn) { /** * Generate and display the overlay to announce cleanup is in progress. * @param {number} count - Number of entries being cleaned up + * @param {boolean} all - Indicate that all thumbnails are being cleared * @returns {ClearMsgCallback} */ -function showCleaningMsg(count) { +function showCleaningMsg(count, all = false) { // Rendering performance isn't a priority since this doesn't run often const parent = el.folders.parentElement; const cleaningOverlay = document.createElement('div'); @@ -936,7 +962,7 @@ function showCleaningMsg(count) { msgText.style.cssText = 'font-size: 1.2em'; msgInfo.style.cssText = 'font-size: 0.9em; text-align: center;'; msgText.innerText = 'Thumbnail cleanup...'; - msgInfo.innerText = `Found ${count} old entries`; + msgInfo.innerText = all ? 'Clearing all entries' : `Found ${count} old entries`; anim.classList.add('idbBusyAnim'); msgDiv.append(msgText, msgInfo); @@ -945,16 +971,17 @@ function showCleaningMsg(count) { return () => { cleaningOverlay.remove(); }; } -const maintenanceQueue = new SimpleFunctionQueue('Maintenance'); +const maintenanceQueue = new SimpleFunctionQueue('Gallery Maintenance'); /** * Handles calling the cleanup function for the thumbnail cache * @param {string} folder - Folder to clean * @param {number} imgCount - Expected number of images in gallery * @param {AbortController} controller - AbortController that's handling this task + * @param {boolean} force - Force full cleanup of the folder */ -async function thumbCacheCleanup(folder, imgCount, controller) { - if (!opts.browser_cache) return; +async function thumbCacheCleanup(folder, imgCount, controller, force = false) { + if (!opts.browser_cache && !force) return; try { if (typeof folder !== 'string' || typeof imgCount !== 'number') { throw new Error('Function called with invalid arguments'); @@ -971,14 +998,14 @@ async function thumbCacheCleanup(folder, imgCount, controller) { callback: async () => { log(`Thumbnail DB cleanup: Checking if "${folder}" needs cleaning`); const t0 = performance.now(); - const staticGalleryHashes = new Set(galleryHashes); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue + const keptGalleryHashes = force ? new Set() : new Set(galleryHashes.values()); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue const cachedHashesCount = await idbCount(folder) .catch((e) => { error(`Thumbnail DB cleanup: Error when getting entry count for "${folder}".`, e); return Infinity; // Forces next check to fail if something went wrong }); - const cleanupCount = cachedHashesCount - staticGalleryHashes.size; - if (cleanupCount < 500 || !Number.isFinite(cleanupCount)) { + const cleanupCount = cachedHashesCount - keptGalleryHashes.size; + if (!force && (cleanupCount < 500 || !Number.isFinite(cleanupCount))) { // Don't run when there aren't many excess entries return; } @@ -988,30 +1015,95 @@ async function thumbCacheCleanup(folder, imgCount, controller) { return; } const cb_clearMsg = showCleaningMsg(cleanupCount); - const tRun = Date.now(); // Doesn't need high resolution - await idbFolderCleanup(staticGalleryHashes, folder, controller.signal) + await idbFolderCleanup(keptGalleryHashes, folder, controller.signal) .then((delcount) => { const t1 = performance.now(); - log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); + log(`Thumbnail DB cleanup: folder=${folder} kept=${keptGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); + currentGalleryFolder = null; + el.clearCacheFolder.innerText = ''; + updateStatusWithSort('Thumbnail cache cleared'); + }) + .catch((e) => { + SimpleFunctionQueue.abortLogger('Thumbnail DB cleanup:', e); + }) + .finally(async () => { + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + cb_clearMsg(); + }); + }, + }); + } +} + +function addCacheClearLabel() { // Don't use async + const setting = document.querySelector('#setting_browser_cache'); + if (setting) { + const div = document.createElement('div'); + div.style.marginBlock = '0.75rem'; + + const span = document.createElement('span'); + span.style.cssText = 'font-weight: bold; text-decoration: underline; cursor: pointer; color: var(--color-blue); user-select: none;'; + span.innerText = '