81 Commits
Author SHA1 Message Date
sn0w ca5a1f36e6 update 2026-10-29 16:16:52 +01:00
sn0w 4567bbfe79 Add lora presets 2026-09-07 14:56:59 +02:00
sn0w dd091e2ab6 improvements 2026-08-16 17:12:03 +02:00
sn0w 805d7a20ac Improve folder matching 2026-06-27 02:34:40 +02:00
sn0w c1270c4b61 Improve character selector 2026-06-27 02:34:31 +02:00
sn0w 6531c579bb Download characters from huggingface 2026-05-18 14:50:37 +02:00
sn0w 80d4c35cac Improve prompt combine 2026-05-11 00:30:26 +02:00
sn0w 33afff0d33 Update upscaler.py 2026-05-10 22:32:08 +02:00
sn0w 241c4ba463 Only load classifier when needed 2026-05-10 22:31:58 +02:00
sn0w ca9e5364d3 Optimize load lora from folder 2026-05-10 22:30:52 +02:00
sn0w e81a8e2403 Optimize find_and_apply_lora 2026-03-06 01:39:45 +01:00
sn0w a1a15f3e45 Fix custom characters not being generated 2026-02-16 20:33:14 +01:00
sn0w e655b86c96 Rewrite prompt combine and implement implied tags 2026-02-16 20:06:25 +01:00
sn0w f222886ff2 Update how character images are saved 2026-02-16 20:05:00 +01:00
Sn0w123 934a07ee92 Fix character name not returning if all its children are hidden 2026-02-13 00:06:52 +01:00
Sn0w123 210caabfba remove body padding 2026-02-11 19:27:15 +01:00
Sn0w123 d260d1e0cf Add character post counts 2026-02-11 19:10:49 +01:00
Sn0w123 f8a9fd3472 Fix generate all characters 2026-02-11 18:02:54 +01:00
Sn0w123 fb21b3b424 Allow hiding individual characters 2026-02-11 17:30:52 +01:00
Sn0w123 6fd3d423a5 Make sure none has callback 2026-02-11 14:50:27 +01:00
Sn0w123 186b64e949 Rewrite character handling 2026-02-11 03:59:12 +01:00
sn0w db1c7a10fd Update visible character manager 2025-10-31 15:48:33 +01:00
sn0w 4cc9df46fd Greatly improve loading speeds by using sqlite instead of loading from json 2025-10-31 14:39:05 +01:00
sn0w c828b0a29e add get character by series endpoint 2025-10-30 21:14:05 +01:00
sn0w ea1b9aa344 Fix character generation saves 2025-10-30 20:45:26 +01:00
Sn0w123 fa43dbe8a7 Fix #15 2025-10-25 23:05:18 +02:00
sn0w 4b9aee6992 Fix issues with multiple parentheses in prompt combine. 2025-10-06 00:57:08 +02:00
sn0w 6fd884fdc5 Change how prompt selector works 2025-10-06 00:56:13 +02:00
sn0w 474f343a20 Remove type system 2025-10-06 00:55:55 +02:00
Lucas Sköld 39eec6877f Implement nodes_upscale_model.py workaround 2025-10-03 11:35:49 +02:00
sn0w b809c5b739 Improve character prompts and loading times 2025-08-25 15:13:02 +02:00
sn0w12 7c5113bb7d Fix incorrect path 2025-07-23 14:41:37 +02:00
Lucas Sköld 77409a85f6 Fix async 2025-07-22 01:47:48 +02:00
Lucas Sköld c0622e25ac Optimize character loading 2025-06-30 15:19:33 +02:00
Lucas Sköld e1c8a5f728 remove character sorting 2025-06-29 23:44:28 +02:00
Lucas Sköld b2f349b2d0 Create requirements.txt 2025-06-29 14:33:35 +02:00
Lucas Sköld eb2398ec18 Add visible series filtering and management UI 2025-06-29 14:33:33 +02:00
Lucas Sköld f029d63435 Add all danbooru characters with over 300 posts 2025-06-29 03:06:57 +02:00
Lucas Sköld 57ebb73dad Early return for SN0W_CHARACTER_SELECTOR 2025-06-28 23:47:51 +02:00
Lucas Sköld bd91ace311 Add a toString for proper data tags in html 2025-06-28 23:43:22 +02:00
Lucas Sköld 23dcfa8002 Nest characters in submenues 2025-06-28 23:02:36 +02:00
Lucas Sköld 8b24769ae1 Get favorites from syntax highlighting node 2025-06-28 16:13:16 +02:00
Lucas Sköld 8e9324de22 Improve settings reading reliablity 2025-06-28 16:03:18 +02:00
Lucas Sköld cefaf2bd6a Create custom upscaler 2025-05-04 17:47:56 +02:00
Lucas Sköld de099e2ad9 Save character images to syntax highlighting 2025-05-04 17:45:15 +02:00
Lucas Sköld 18408d6d94 Do not deduplicate tags in parentheses 2025-04-07 00:35:30 +02:00
Lucas Sköld e391de23f2 Add new characters 2025-04-07 00:35:01 +02:00
Lucas Sköld edc0fc8af2 Fix conversion 2025-03-18 17:56:06 +01:00
Lucas Sköld d17bced11d Move all highlighting to new extension 2025-03-18 17:33:52 +01:00
Lucas Sköld d1160f2ac5 Update 2025-01-27 15:44:03 +01:00
Lucas Sköld 3b18403d86 Update prompts.json 2025-01-27 15:43:16 +01:00
Lucas Sköld 3ec8fa68c1 Add node to generate all needed character images 2025-01-12 02:16:22 +01:00
Lucas Sköld 44ee36e996 Skip empty tags 2025-01-11 00:23:39 +01:00
Lucas Sköld e6eac9f997 Add setting for hover delay 2025-01-10 20:50:08 +01:00
Lucas Sköld 7aa2efd684 Do not let preview image go off screen 2025-01-10 18:27:12 +01:00
Lucas Sköld 98ceee382e allow image subfolders 2025-01-10 18:05:57 +01:00
Lucas Sköld af8f95bde8 Build an index of the images on start and use that 2025-01-10 16:28:57 +01:00
Lucas Sköld 59db9ed1af Add optional images to context menus 2025-01-10 16:14:20 +01:00
Lucas Sköld 281edcfbef Update prompts.json 2025-01-10 16:08:09 +01:00
Lucas Sköld f792731fe8 Update prompts.json 2025-01-08 16:53:40 +01:00
Lucas Sköld 7605c78a52 Add comment 2024-11-26 20:19:11 +01:00
Lucas Sköld 0cc76218fe Combine parentheses if simplify is on 2024-11-26 20:17:13 +01:00
Lucas Sköld 256a6dcfb0 Lint 2024-11-26 17:04:20 +01:00
Lucas Sköld 49e071f551 Create .prettierrc 2024-11-26 17:04:13 +01:00
Lucas Sköld 5549adcf51 Update .gitignore 2024-11-16 21:42:41 +01:00
Lucas Sköld 719aec3098 Add back removed observer. 2024-09-16 02:51:54 +02:00
sn0w12 a5a03d2a4b Fix #13 2024-09-14 21:45:18 +02:00
sn0w12 a7b538e773 Log textcolor based on luminance 2024-08-24 21:08:46 +02:00
sn0w12 4e0cd8b992 Fix settings fetching 2024-08-24 20:47:51 +02:00
sn0w12 a2aeb110cb Update logging, remove settings cache. 2024-08-24 20:40:03 +02:00
sn0w12 4fe93f0c44 Update js logging. 2024-08-24 20:30:02 +02:00
Lucas Sköld a51ddb6c57 Small optimizations. 2024-08-23 00:42:25 +02:00
Lucas Sköld b8da34e35e Update README.md 2024-08-18 23:12:59 +02:00
Lucas Sköld f227997bb3 Fix tooltip 2024-08-17 21:55:07 +02:00
Lucas Sköld d133077e69 Move registerSetting to sn0w.js 2024-08-17 21:52:44 +02:00
Lucas Sköld b9e725d12d Update all settings to look better with new ui. 2024-08-17 21:42:00 +02:00
Lucas Sköld 81a03f8b99 Fix fetching 2024-08-17 16:18:36 +02:00
Lucas Sköld 103d3b0277 Don't add duplicate loras to context menu 2024-08-17 16:02:59 +02:00
Lucas Sköld 2bfbfc63ef Remove double slash 2024-08-17 16:02:38 +02:00
Lucas Sköld 1a4b0104f4 Fix optional type validation error 2024-08-16 21:34:21 +02:00
Lucas Sköld da1ded76af Update .gitignore 2024-08-16 21:34:09 +02:00
39 changed files with 5122 additions and 2061 deletions
+14
View File
@@ -12,3 +12,17 @@ src/prompt_combine_testing.py
web/settings/sn0w_settings.json web/settings/sn0w_settings.json
testing/test_scheduler.py testing/test_scheduler.py
update-dev.bat update-dev.bat
/.vscode
prompts.json
web/images/*.png
web/images/*.jpg
web/images/*.jpeg
web/images/*.gif
web/images/*.webp
web/images/images.json
web/images/*/*
/models
web/settings/visible_series.json
web/settings/hidden_characters.json
web/settings/lora_presets.json
web/characters/characters.db
+6
View File
@@ -0,0 +1,6 @@
{
"tabWidth": 4,
"useTabs": false,
"endOfLine": "auto",
"printWidth": 999
}
+147 -78
View File
@@ -1,11 +1,16 @@
<h1 align="center"> <h1 align="center">
sn0w-scripts sn0w-scripts
</h1> </h1>
<div align="center">
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/sn0w12/ComfyUI-Sn0w-Scripts/github-code-scanning%2Fcodeql">
<a href="https://app.codacy.com/gh/sn0w12/ComfyUI-Sn0w-Scripts/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade"><img src="https://app.codacy.com/project/badge/Grade/0f33e71f5385411ca2b651e8b4a5252f"/></a>
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/sn0w12/ComfyUI-Sn0w-Scripts">
</div>
This extension contains a collection of nodes and improvements created for lora management and general quality of life. This extension contains a collection of nodes and improvements created for lora management and general quality of life.
> [!IMPORTANT] > [!IMPORTANT]
> If you have saved a workflow with the `sn0w ksampler` node, make sure to re-save the workflow before the new comfyui frontend is implemented as the old implementation will corrupt your workflow. > All highlighting/ favourite handling has been moved to [ComfyUI-Syntax-Highlighting](https://github.com/sn0w12/ComfyUI-Syntax-Highlighting)
# Get Started # Get Started
@@ -26,23 +31,20 @@ In the ComfyUI settings, you can modify various settings. Some changes will appl
## Settings ## Settings
| Setting | Options | Definition | | Setting | Options | Definition |
| ----------- | ----------- | ----------- | | -------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------- |
| Animagine Prompt Style | True / False | Places "1girl/1boy" at the beginning of your prompts in the "Prompt Combine" node. | | Animagine Prompt Style | True / False | Places "1girl/1boy" at the beginning of your prompts in the "Prompt Combine" node. |
| Custom Lora Loaders SD... | Textbox | Allows creation of new LoRa loaders by specifying their name and path. | | Custom Lora Loaders SD... | Textbox | Allows creation of new LoRa loaders by specifying their name and path. |
| Custom Textbox Colors | Textbox | Sets the highlight color for text in textboxes. | | Disable Default Characters | True / False | Disables the loading of the default characters in the "Character Selector" node. |
| Custom Textbox Gradient Highlight | True / False | Makes the textbox highlighting be a gradient between the first and last color based on the strength of the selection. | | Logging Level | Warnings<br>Informational<br>Debug | Sets the severity level of logs that will be printed. |
| Disable Default Characters | True / False | Disables the loading of the default characters in the "Character Selector" node. | | Max Difference in Lora Loading | 0 - 100 | Specifies the maximum allowable difference between a tag and a LoRa for it to be loaded. |
| Highlight Favourite Items | True / False | Highlights favorite LoRas and characters in green. | | Random Characters from Favourites Only | True / False | Ensures random characters are selected only from your favorited characters. |
| Logging Level | Warnings<br>Informational<br>Debug | Sets the severity level of logs that will be printed. | | Remove Lora Path | True / False | Removes the path information from loras and only keeps the name. |
| Max Difference in Lora Loading | 0 - 100 | Specifies the maximum allowable difference between a tag and a LoRa for it to be loaded. | | Sort Characters By | Alphabetically<br>Series | Sorts characters either alphabetically or by their series. |
| Random Characters from Favourites Only | True / False | Ensures random characters are selected only from your favorited characters. | | Sort Loras By | Alphabetically<br>Last Changed | Sorts loras either alphabetically or by latest changed. |
| Remove Lora Path | True / False | Removes the path information from loras and only keeps the name. |
| Sort Characters By | Alphabetically<br>Series | Sorts characters either alphabetically or by their series. |
| Sort Loras By | Alphabetically<br>Last Changed | Sorts loras either alphabetically or by latest changed. |
> [!IMPORTANT] > [!IMPORTANT]
> The custom lora loaders require these paths to be in your `extra_model_paths.yaml` (with your real directories of course). You only need to add the ones you are actively using. > The custom lora loaders require these paths to be in your `extra_model_paths.yaml` (with your real directories of course). You only need to add the ones you are actively using.
```yaml ```yaml
loras: loras:
@@ -58,14 +60,17 @@ loras:
## Lora Management ## Lora Management
A major focus for me was making loras more manageable by letting users create their own lora loaders with specific folder paths and automatically loading loras based on tags. This makes browsing through your Loras much easier, though it may limit which Loras you can use depending on your configuration. You can configure custom Lora loaders in the ComfyUI settings with the following format: A major focus for me was making loras more manageable by letting users create their own lora loaders with specific folder paths and automatically loading loras based on tags. This makes browsing through your Loras much easier, though it may limit which Loras you can use depending on your configuration. You can configure custom Lora loaders in the ComfyUI settings with the following format:
``` ```
ExampleName1:Value1 ExampleName1:Value1
``` ```
To create a Lora stack, add a number to the end: To create a Lora stack, add a number to the end:
``` ```
Styles XL:style:2 Styles XL:style:2
``` ```
![Custom Lora Loader](./imgs/custom_lora_loader.png) ![Custom Lora Loader](./imgs/custom_lora_loader.png)
### Favourite Loras & Characters ### Favourite Loras & Characters
@@ -82,9 +87,9 @@ There are options to sort your loras either alphabetically or by the latest chan
Text in the copy/paste textbox will be highlighted when the text is in parentheses. You can change the colors of the highlighting by changing the `[Sn0w] Custom Textbox Colors` setting, there should be one either rgb `rgb(0, 0, 0)` or hex `#000000` color per line. You can choose to either do the highlighting by the strength of the text or the nested level it is in. Text in the copy/paste textbox will be highlighted when the text is in parentheses. You can change the colors of the highlighting by changing the `[Sn0w] Custom Textbox Colors` setting, there should be one either rgb `rgb(0, 0, 0)` or hex `#000000` color per line. You can choose to either do the highlighting by the strength of the text or the nested level it is in.
Nesting | Strength | Nesting | Strength |
:-------------------------:|:-------------------------: | :----------------------------------------: | :-------------------------------------: |
![Textbox](./imgs/textbox_highlighted.png) | ![Textbox](./imgs/textbox_gradient.png) | ![Textbox](./imgs/textbox_highlighted.png) | ![Textbox](./imgs/textbox_gradient.png) |
If you have unclosed parentheses it will be highlighted in red. If you have unclosed parentheses it will be highlighted in red.
@@ -104,22 +109,30 @@ A custom scheduler that generally produces similar quality results as most other
> <summary><i>Comparisons</i></summary> > <summary><i>Comparisons</i></summary>
> Images generated at 20 steps, 8 cfg, 1152 x 896, no upscaling.<br> Sigmoid settings at 20 sigma max, 0 sigma min, 3.5 steepness and 0.8 midpoint ratio. > Images generated at 20 steps, 8 cfg, 1152 x 896, no upscaling.<br> Sigmoid settings at 20 sigma max, 0 sigma min, 3.5 steepness and 0.8 midpoint ratio.
> >
>``` > ```
>(score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1), 1girl, lips, long hair, grey eyes, black coat, medium breasts, cowboy shot, black robe, gold jewelry, looking at viewer, simple background, solo, white background, closed mouth, arms behind back, (red hair, dress, long dress, robe, white dress, gold trim:1.1) > (score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1), 1girl, lips, long hair, grey eyes, black coat, medium breasts, cowboy shot, black robe, gold jewelry, looking at viewer, simple background, solo, white background, closed mouth, arms behind back, (red hair, dress, long dress, robe, white dress, gold trim:1.1)
>``` > ```
> ![Sigmoid Comparison](./imgs/comparison_1.png) >
> ``` > ![Sigmoid Comparison](./imgs/comparison_1.png)
>(power \(chainsaw man\), chainsaw man:1.1), blonde hair, cross-shaped pupils, demon girl, demon horns, double-parted bangs, hair between eyes, long hair, red horns, symbol-shaped pupils, yellow eyes, 1girl, black necktie, black pants, collared shirt, double v, looking at viewer, necktie, orange eyes, pants, parted lips, red background, red jacket, sharp teeth, shirt partially tucked in, shirt tucked in, simple background, sleeves past wrists, smile, solo, teeth, v, white shirt, (score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1) >
>``` > ```
> ![Sigmoid Comparison](./imgs/comparison_2.png) > (power \(chainsaw man\), chainsaw man:1.1), blonde hair, cross-shaped pupils, demon girl, demon horns, double-parted bangs, hair between eyes, long hair, red horns, symbol-shaped pupils, yellow eyes, 1girl, black necktie, black pants, collared shirt, double v, looking at viewer, necktie, orange eyes, pants, parted lips, red background, red jacket, sharp teeth, shirt partially tucked in, shirt tucked in, simple background, sleeves past wrists, smile, solo, teeth, v, white shirt, (score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1)
> ``` > ```
>(score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1), (makima \(chainsaw man\), chainsaw man:1.1), hair between eyes, long hair, medium breasts, red hair, ringed eyes, yellow eyes, 1836547, 1girl, @ \(symbol\), black necktie, braided ponytail, business suit, collared shirt, formal, light smile, looking at viewer, necktie, office lady, sidelocks, simple background, smile, solo, suit, white shirt >
>``` > ![Sigmoid Comparison](./imgs/comparison_2.png)
> ![Sigmoid Comparison](./imgs/comparison_3.png) >
> ``` > ```
>(score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1), (makima \(chainsaw man\), chainsaw man:1.1), hair between eyes, long hair, medium breasts, red hair, ringed eyes, yellow eyes, 1girl, black jacket, black necktie, black pants, black vest, closed eyes, collared shirt, formal, necktie, pants, shirt tucked in, simple background, sitting, solo, suit jacket, vest, white background, white shirt > (score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1), (makima \(chainsaw man\), chainsaw man:1.1), hair between eyes, long hair, medium breasts, red hair, ringed eyes, yellow eyes, 1836547, 1girl, @ \(symbol\), black necktie, braided ponytail, business suit, collared shirt, formal, light smile, looking at viewer, necktie, office lady, sidelocks, simple background, smile, solo, suit, white shirt
>``` > ```
> ![Sigmoid Comparison](./imgs/comparison_4.png) >
> ![Sigmoid Comparison](./imgs/comparison_3.png)
>
> ```
> (score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1), (makima \(chainsaw man\), chainsaw man:1.1), hair between eyes, long hair, medium breasts, red hair, ringed eyes, yellow eyes, 1girl, black jacket, black necktie, black pants, black vest, closed eyes, collared shirt, formal, necktie, pants, shirt tucked in, simple background, sitting, solo, suit jacket, vest, white background, white shirt
> ```
>
> ![Sigmoid Comparison](./imgs/comparison_4.png)
>
> </details> > </details>
#### Sigmoid graph #### Sigmoid graph
@@ -133,28 +146,34 @@ A custom scheduler that generally produces similar quality results as most other
Nodes that are specifically for generating and processing images. Nodes that are specifically for generating and processing images.
### Sn0w KSampler ### Sn0w KSampler
> Ksampler with more settings and optional inputs. Support for custom schedulers > Ksampler with more settings and optional inputs. Support for custom schedulers
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - Can take both text and conditioning as positive and negative input. > - Can take both text and conditioning as positive and negative input.
> - Can take a latent image or can generate one if none is provided. > - Can take a latent image or can generate one if none is provided.
> - Supports schedulers such as align your steps, you can also create your own scheduler in `src/custom_schedulers/`, make sure to follow the example provided in `get_sigmas_sigmoid.py`. > - Supports schedulers such as align your steps, you can also create your own scheduler in `src/custom_schedulers/`, make sure to follow the example provided in `get_sigmas_sigmoid.py`.
> - You can also just get the sigmas from any of the scheduler nodes and drag them into `sigmas (optional)`, if you do the scheduler widget will not be used. > - You can also just get the sigmas from any of the scheduler nodes and drag them into `sigmas (optional)`, if you do the scheduler widget will not be used.
>
> ![Simple Sampler](./imgs/simple_sampler.png)
> >
> ![Simple Sampler](./imgs/simple_sampler.png)
> </details> > </details>
### Lora Tester ### Lora Tester
> Automatically generates X amount of images with provided loras. > Automatically generates X amount of images with provided loras.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - I recommend making lora_info and add_default_generation inputs and using the outputs from the Lora Selector. > - I recommend making lora_info and add_default_generation inputs and using the outputs from the Lora Selector.
> - Takes normal KSampler input but takes positive and negative inputs as text. > - Takes normal KSampler input but takes positive and negative inputs as text.
> - Outputs a batch of images. > - Outputs a batch of images.
>
> ![Lora Tester](./imgs/lora_tester.png)
> >
> ![Lora Tester](./imgs/lora_tester.png)
> </details> > </details>
## Lora Management ## Lora Management
@@ -162,131 +181,181 @@ Nodes that are specifically for generating and processing images.
Nodes that handle lora selection, stacking, and loading. Nodes that handle lora selection, stacking, and loading.
### Load Lora Sn0w ### Load Lora Sn0w
> Normal load lora but can put favourite loras at the top of the list. > Normal load lora but can put favourite loras at the top of the list.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> ![Load Lora](./imgs/load_lora.png) > ![Load Lora](./imgs/load_lora.png)
>
> </details> > </details>
### Load Lora Folder ### Load Lora Folder
> Dynamically applies Lora models based on similarity to a provided prompt. > Dynamically applies Lora models based on similarity to a provided prompt.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - This node processes a given prompt to identify and apply the most similar Lora models of the tags found in the prompt. > - This node processes a given prompt to identify and apply the most similar Lora models of the tags found in the prompt.
> - For each part of the prompt, the node calculates a distance between tags and available Lora model filenames. The max distance can be chosen in the settings. > - For each part of the prompt, the node calculates a distance between tags and available Lora model filenames. The max distance can be chosen in the settings.
> - **Example folder input:** `*master_folder, subfolder1:3, -excludefolder, subfolder2` > - **Example folder input:** `*master_folder, subfolder1:3, -excludefolder, subfolder2`
> - `*master_folder` specifies all paths must include `master_folder`. > - `*master_folder` specifies all paths must include `master_folder`.
> - `subfolder1:3` indicates up to 3 models from `subfolder1` can be loaded. > - `subfolder1:3` indicates up to 3 models from `subfolder1` can be loaded.
> - `-excludefolder` ensures any path containing `excludefolder` is ignored. > - `-excludefolder` ensures any path containing `excludefolder` is ignored.
> - `subfolder2` loads models from this subfolder without a numeric limit. > - `subfolder2` loads models from this subfolder without a numeric limit.
> - Note: You need the loras separated into XL and 1.5 as stated in the `Important Note`. > - Note: You need the loras separated into XL and 1.5 as stated in the `Important Note`.
> - Note: Loras need to be named very similarly to the tag, with at most 5 characters different. Words in the lora filename can be separated by spaces or underscores. > - Note: Loras need to be named very similarly to the tag, with at most 5 characters different. Words in the lora filename can be separated by spaces or underscores.
>
> ![Load Lora Folder](./imgs/load_lora_folder.png)
> >
> ![Load Lora Folder](./imgs/load_lora_folder.png)
> </details> > </details>
### Lora Selector ### Lora Selector
> Automatically selects X amount of loras between two numbers. > Automatically selects X amount of loras between two numbers.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - Loras have to be formatted like the default kohya_ss outputs. (lora_name-000001) > - Loras have to be formatted like the default kohya_ss outputs. (lora_name-000001)
> - Select the first lora. > - Select the first lora.
> - Select the number of the highest lora you want to test. > - Select the number of the highest lora you want to test.
> - Select the amount of loras you want to test. > - Select the amount of loras you want to test.
> - Outputs list of loras like this: \<lora:name:strength> > - Outputs list of loras like this: \<lora:name:strength>
> - Add default generation adds an extra "nothing" at the end of the list, used in Lora Tester to generate an image without the lora. > - Add default generation adds an extra "nothing" at the end of the list, used in Lora Tester to generate an image without the lora.
>
> ![Lora Selector](./imgs/lora_selector.png)
> >
> ![Lora Selector](./imgs/lora_selector.png)
> </details> > </details>
### Lora Stacker ### Lora Stacker
> Outputs a list of loras for Lora Tester. Does not load any loras by itself. > Outputs a list of loras for Lora Tester. Does not load any loras by itself.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - Basically Lora Selector but manual. > - Basically Lora Selector but manual.
> - Specifically for the lora testers. > - Specifically for the lora testers.
>
> ![Lora Stacker](./imgs/lora_stacker.png)
> >
> ![Lora Stacker](./imgs/lora_stacker.png)
> </details> > </details>
## Character and Text Processing ## Character and Text Processing
Nodes for selecting and combining character and textual elements. Nodes for selecting and combining character and textual elements.
### Character Selector ### Character Selector
> Outputs a character name and prompt. > Outputs a character name and prompt.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - Loads `characters.json` and outputs prompt based on it. > - Loads `characters.json` and outputs prompt based on it.
> - You can create a file named `custom_characters.json` and add characters there if you want, they will be loaded with all the other characters if you format it like the `characters.json` file. > - You can create a file named `custom_characters.json` and add characters there if you want, they will be loaded with all the other characters if you format it like the `characters.json` file.
> - If `custom_characters.json` has a character with the same name as `characters.json` it will add the custom prompt at the end of the normal one, this can be useful if you have loras that need activation tags. > - If `custom_characters.json` has a character with the same name as `characters.json` it will add the custom prompt at the end of the normal one, this can be useful if you have loras that need activation tags.
>
> ![Character Selector](./imgs/character_selector.png)
>
> </details>
### Character Prompt Injector
> Injects up to four characters into a natural language prompt while keeping it readable as prose.
> <details>
> <summary>ℹ️ <i>See More Information</i></summary>
>
> - Uses the same character list as the Character Selector.
> - Reference characters in the prompt with `{{char1}}` through `{{char4}}`. Each placeholder is replaced by the character selected in the matching slot.
> - The first mention of a character gets the name and series plus a description built from hair, eye, and body tags, with hair colour, length, and style merged into one phrase (e.g. "Darkness from Konosuba, a girl with long blonde hair tied in a ponytail, blue eyes, and large breasts"). Clothing and accessory tags are ignored, and later mentions use only the name.
> - Connect the Character Selector's `CHARACTER NAME` output to a slot instead of using its dropdown.
> - A prompt without placeholders falls back to the single-character behavior: the character in slot 1 replaces a generic subject such as "a girl", or is added as a "featuring ..." clause.
> >
> ![Character Selector](./imgs/character_selector.png)
> </details> > </details>
### Prompt Combine ### Prompt Combine
> Combines multiple strings with a specified separator and optionally simplifies the result by removing redundant or incompatible tags. > Combines multiple strings with a specified separator and optionally simplifies the result by removing redundant or incompatible tags.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - If simplify is enabled, the simplification process identifies and removes redundant tags (e.g., when a tag is fully encompassed by another, more descriptive tag) and tags incompatible with factors such as facing away, covered eyes, etc. Any tags that are in parentheses will not be removed. > - If simplify is enabled, the simplification process identifies and removes redundant tags (e.g., when a tag is fully encompassed by another, more descriptive tag) and tags incompatible with factors such as facing away, covered eyes, etc. Any tags that are in parentheses will not be removed.
>
> ![Prompt Combine](./imgs/prompt_combine.png)
> >
> ![Prompt Combine](./imgs/prompt_combine.png)
> </details> > </details>
## Utility Nodes ## Utility Nodes
Nodes that provide utility functions across the system. Nodes that provide utility functions across the system.
### Get Font Size Node ### Get Font Size Node
> Estimates the optimal font size for text to fit within an image based on Lora information. > Estimates the optimal font size for text to fit within an image based on Lora information.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> - This node takes an image and a string, estimating the best font size to ensure that the longest piece of the string fits within the image. The approach considers the image's width and the length of the text to find a size that balances visibility and fit. > - This node takes an image and a string, estimating the best font size to ensure that the longest piece of the string fits within the image. The approach considers the image's width and the length of the text to find a size that balances visibility and fit.
>
> ![Get Font Size](./imgs/get_font_size.png)
> >
> ![Get Font Size](./imgs/get_font_size.png)
> </details> > </details>
### Copy/Paste Textbox ### Copy/Paste Textbox
> A multiline textbox with copy, paste and highlighting functionality. > A multiline textbox with copy, paste and highlighting functionality.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> ![Textbox](./imgs/textbox.png) > ![Textbox](./imgs/textbox.png)
>
> </details> > </details>
### Upscale Image With Model By ### Upscale Image With Model By
> Upscales an image by a certain amount with an upscale model. > Upscales an image by a certain amount with an upscale model.
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> ![Upscaler](./imgs/upscale.png) > ![Upscaler](./imgs/upscale.png)
>
> </details> > </details>
### Filter Tags ### Filter Tags
> Filters a string to only returns tags that have to do with a chosen category. // WIP > Filters a string to only returns tags that have to do with a chosen category. // WIP
>
> <details> > <details>
> <summary>ℹ️ <i>See More Information</i></summary> > <summary>ℹ️ <i>See More Information</i></summary>
> >
> ![Filter Tags](./imgs/filter_tags.png) > ![Filter Tags](./imgs/filter_tags.png)
>
> </details> > </details>
<br> > <br>
# Example Workflows # Example Workflows
## Lora Tester ## Lora Tester
Note: Uses [ImagesGrid](https://github.com/LEv145/images-grid-comfy-plugin) for making the actual grid from the image output. Note: Uses [ImagesGrid](https://github.com/LEv145/images-grid-comfy-plugin) for making the actual grid from the image output.
![Lora Tester](./imgs/lora_tester_workflow.png) ![Lora Tester](./imgs/lora_tester_workflow.png)
## T2I ## T2I
My general text to image workflow, note that it uses several nodes from different custom nodes. My general text to image workflow, note that it uses several nodes from different custom nodes.
![T2I](./imgs/normal_workflow.png) ![T2I](./imgs/normal_workflow.png)
# Known Issues # Known Issues
* Embedding highlighting does not work on linux. - Embedding highlighting does not work on linux.
+120 -11
View File
@@ -1,5 +1,9 @@
import importlib import importlib
import json
import os import os
from server import PromptServer
from aiohttp import web
from .src.dynamic_lora_loader import generate_lora_node_class from .src.dynamic_lora_loader import generate_lora_node_class
from .src.dynamic_scheduler_loader import generate_scheduler_node_class from .src.dynamic_scheduler_loader import generate_scheduler_node_class
from .src.check_folder_paths import check_lora_folders from .src.check_folder_paths import check_lora_folders
@@ -7,7 +11,8 @@ from .sn0w import ConfigReader, Logger
from .src.lora_selector import LoraSelectorNode from .src.lora_selector import LoraSelectorNode
from .src.lora_tester import LoraTestNode from .src.lora_tester import LoraTestNode
from .src.character_select import CharacterSelectNode from .src.character_select import CharacterSelectNode, CharacterDB
from .src.character_prompt_inject import CharacterPromptInjectNode
from .src.prompt_combine import CombineStringNode from .src.prompt_combine import CombineStringNode
from .src.lora_stacker import LoraStackerNode from .src.lora_stacker import LoraStackerNode
from .src.get_font_size import GetFontSizeNode from .src.get_font_size import GetFontSizeNode
@@ -17,6 +22,9 @@ from .src.load_lora_from_folder import LoadLoraFolderNode
from .src.textbox import TextboxNode from .src.textbox import TextboxNode
from .src.simple_ksampler import SimpleSamplerCustom from .src.simple_ksampler import SimpleSamplerCustom
from .src.filter_tags import FilterTags from .src.filter_tags import FilterTags
from .src.generate_all_character_images import GenerateCharactersNode
from .src.upscaler import AutoTaggedTiledUpscaler
from .src.lora_presets import build_lora_loader_info, save_preset
# Constants # Constants
WEB_DIRECTORY = "./web" WEB_DIRECTORY = "./web"
@@ -27,6 +35,7 @@ NODE_CLASS_MAPPINGS = {
"Lora Selector": LoraSelectorNode, "Lora Selector": LoraSelectorNode,
"Lora Tester": LoraTestNode, "Lora Tester": LoraTestNode,
"Character Selector": CharacterSelectNode, "Character Selector": CharacterSelectNode,
"Character Prompt Injector": CharacterPromptInjectNode,
"Prompt Combine": CombineStringNode, "Prompt Combine": CombineStringNode,
"Sn0w Lora Stacker": LoraStackerNode, "Sn0w Lora Stacker": LoraStackerNode,
"Load Lora Sn0w": generate_lora_node_class("loras"), "Load Lora Sn0w": generate_lora_node_class("loras"),
@@ -37,12 +46,15 @@ NODE_CLASS_MAPPINGS = {
"Copy/Paste Textbox": TextboxNode, "Copy/Paste Textbox": TextboxNode,
"Sn0w KSampler": SimpleSamplerCustom, "Sn0w KSampler": SimpleSamplerCustom,
"Filter Tags": FilterTags, "Filter Tags": FilterTags,
"Generate All Characters": GenerateCharactersNode,
"TaggedTiledUpscaler": AutoTaggedTiledUpscaler,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"Lora Selector": "Lora Selector", "Lora Selector": "Lora Selector",
"Lora Tester": "Lora Tester", "Lora Tester": "Lora Tester",
"Character Selector": "Character Selector", "Character Selector": "Character Selector",
"Character Prompt Injector": "Character Prompt Injector",
"Prompt Combine": "Prompt Combine", "Prompt Combine": "Prompt Combine",
"Sn0w Lora Stacker": "Sn0w Lora Stacker", "Sn0w Lora Stacker": "Sn0w Lora Stacker",
"Load Lora Sn0w": "Load Lora Sn0w", "Load Lora Sn0w": "Load Lora Sn0w",
@@ -53,6 +65,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Copy/Paste Textbox": "Textbox", "Copy/Paste Textbox": "Textbox",
"Sn0w KSampler": "Sn0w KSampler", "Sn0w KSampler": "Sn0w KSampler",
"Filter Tags": "Filter Tags", "Filter Tags": "Filter Tags",
"Generate All Characters": "Generate All Character Images",
"TaggedTiledUpscaler": "Tagged Tiled Upscaler",
} }
# Function to check required folder paths # Function to check required folder paths
@@ -91,21 +105,21 @@ def generate_and_register_lora_node(lora_type, setting):
logger.log(f"Adding custom lora loader. Path: {folders}, Name: {name}, Inputs: {combos}", "INFORMATIONAL") logger.log(f"Adding custom lora loader. Path: {folders}, Name: {name}, Inputs: {combos}", "INFORMATIONAL")
DynamicLoraNode = generate_lora_node_class(lora_type, folders, combos) DynamicLoraNode = generate_lora_node_class(lora_type, folders, combos)
if DynamicLoraNode:
if name in NODE_CLASS_MAPPINGS: if name in NODE_CLASS_MAPPINGS:
NODE_CLASS_MAPPINGS[unique_id_with_name] = DynamicLoraNode NODE_CLASS_MAPPINGS[unique_id_with_name] = DynamicLoraNode
NODE_DISPLAY_NAME_MAPPINGS[unique_id_with_name] = f"{name}" NODE_DISPLAY_NAME_MAPPINGS[unique_id_with_name] = f"{name}"
else: else:
NODE_CLASS_MAPPINGS[name] = DynamicLoraNode NODE_CLASS_MAPPINGS[name] = DynamicLoraNode
NODE_DISPLAY_NAME_MAPPINGS[name] = f"{name}" NODE_DISPLAY_NAME_MAPPINGS[name] = f"{name}"
def generate_and_register_all_lora_nodes(): def generate_and_register_all_lora_nodes():
"""Generate and register all custom lora nodes.""" """Generate and register all custom lora nodes."""
lora_types = [ lora_types = [
("loras_xl", "sn0w.CustomLoraLoadersXL"), ("loras_xl", "sn0w.CustomLoraLoaders.XL"),
("loras_15", "sn0w.CustomLoraLoaders1.5"), ("loras_15", "sn0w.CustomLoraLoaders"),
("loras_3", "sn0w.CustomLoraLoaders3"), ("loras_3", "sn0w.CustomLoraLoaders.3"),
] ]
for lora_type, setting in lora_types: for lora_type, setting in lora_types:
generate_and_register_lora_node(lora_type, setting) generate_and_register_lora_node(lora_type, setting)
@@ -146,3 +160,98 @@ def register_scheduler_node(module):
# Register custom nodes # Register custom nodes
generate_and_register_all_lora_nodes() generate_and_register_all_lora_nodes()
import_and_register_scheduler_nodes() import_and_register_scheduler_nodes()
API_PREFIX = "/sn0w"
@PromptServer.instance.routes.get(f"{API_PREFIX}/lora_loader_info")
async def lora_loader_info_endpoint(request):
return web.json_response(build_lora_loader_info(NODE_CLASS_MAPPINGS))
@PromptServer.instance.routes.post(f"{API_PREFIX}/lora_presets")
async def save_lora_preset(request):
data = await request.json()
if data is None or not isinstance(data, dict):
return web.json_response({"error": "Invalid request, expected a JSON object"}, status=400)
name = str(data.get("name", "")).strip()
path = str(data.get("path", "")).strip()
loras = data.get("loras")
if not name or not path or not isinstance(loras, list):
return web.json_response({"error": "Invalid request, 'name', 'path' and 'loras' are required"}, status=400)
cleaned_loras = []
for entry in loras[:26]:
if not isinstance(entry, dict):
continue
lora = str(entry.get("lora", "")).strip()
if not lora:
continue
try:
strength = float(entry.get("strength", 1.0))
except (TypeError, ValueError):
strength = 1.0
cleaned_loras.append({"lora": lora, "strength": strength})
if not save_preset(name, path, cleaned_loras):
return web.json_response({"error": "Failed to save preset"}, status=500)
return web.json_response({"status": "ok"})
@PromptServer.instance.routes.get(f"{API_PREFIX}/series")
async def series_endpoint(request):
db = CharacterDB()
series = db.get_all_series_with_post_counts(remove_hidden_characters=False)
return web.json_response(series)
@PromptServer.instance.routes.get(f"{API_PREFIX}/visible_series")
async def get_visible_series(request):
db = CharacterDB()
visible_series = db.load_visible_series()
return web.json_response({"visible_series": list(visible_series) if visible_series else []})
@PromptServer.instance.routes.post(f"{API_PREFIX}/visible_series")
async def set_visible_series(request):
data = await request.json()
if data is None or "visible_series" not in data or not isinstance(data["visible_series"], list):
return web.json_response({"error": "Invalid request, 'visible_series' key missing or not a list"}, status=400)
db = CharacterDB()
db.set_visible_series(data.get("visible_series", []))
return web.json_response({"status": "ok"})
@PromptServer.instance.routes.get(f"{API_PREFIX}/hidden_characters")
async def get_hidden_characters(request):
db = CharacterDB()
hidden = db.load_hidden_characters()
return web.json_response({"hidden_characters": list(hidden) if hidden else []})
@PromptServer.instance.routes.get(f"{API_PREFIX}/auto_hide_notice")
async def get_auto_hide_notice(request):
notice = dict(CharacterDB.last_auto_hide)
CharacterDB.last_auto_hide = {"count": 0, "characters": [], "series": []}
return web.json_response(notice)
@PromptServer.instance.routes.post(f"{API_PREFIX}/hidden_characters")
async def set_hidden_characters(request):
data = await request.json()
if data is None or "hidden_characters" not in data or not isinstance(data["hidden_characters"], list):
return web.json_response({"error": "Invalid request, 'hidden_characters' key missing or not a list"}, status=400)
db = CharacterDB()
db.set_hidden_characters(data.get("hidden_characters", []))
return web.json_response({"status": "ok"})
@PromptServer.instance.routes.get(f"{API_PREFIX}/series_selector")
async def serve_series_selector(request):
path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "web", "characters", "index.html")
with open(path, "r", encoding="utf-8") as f:
html = f.read()
return web.Response(text=html, content_type="text/html")
+17 -40
View File
@@ -1,41 +1,18 @@
[ {
{ "Animagine": {
"positive": [ "positive": "(masterpiece, exceptional, best aesthetic, best quality, masterpiece, extremely detailed:1.1)",
{ "negative": "(lowres, bad anatomy, bad hands, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts:1.15), (signature, watermark, username, gumroad username, patreon username, artist name:1.1), multiple views, text, error, blurry"
"name": "Positive_Ani", },
"prompt": "(masterpiece, exceptional, best aesthetic, best quality, masterpiece, extremely detailed:1.1), " "Pony": {
}, "positive": "(score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1)",
{ "negative": "score_4, score_3, score_2, score_1, score_0, censored, monochrome, 3d, signature, watermark, username, speech bubble"
"name": "Positive Pony", },
"prompt": "(score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK:1.1)" "Illust": {
}, "positive": "masterpiece, best quality, absurdres, highres",
{ "negative": "worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, deformed hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro"
"name": "Positive Pony Explicit", },
"prompt": "(score_9, score_8_up, score_7_up, score_6_up, source_anime, BREAK, rating_explicit, uncensored:1.1)" "Anima": {
}, "positive": "masterpiece, best quality, score_7",
{ "negative": "worst quality, low quality, score_1, score_2, score_3, deviant art, artist name, bad hands, mutated hands, deformed hands"
"name": "Positive Pony Realistic",
"prompt": "(score_9, score_8_up, score_7_up, score_6_up, photo \\(medium\\), BREAK:1.1)"
}
],
"negative": [
{
"name": "Negative_XL_Ani",
"prompt": "(lowres, bad anatomy, bad hands, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts:1.15), (signature, watermark, username, gumroad username, patreon username, artist name:1.1), multiple views, text, error, blurry"
},
{
"name": "Negative_safe_XL_Ani",
"prompt": "(lowres, bad anatomy, bad hands, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts:1.15), (rating:explicit, rating:questionable, rating:sensitive, signature, watermark, username, gumroad username, patreon username, artist name:1.1), multiple views, text, error, blurry"
},
{
"name": "Negative_1.5",
"prompt": "embedding:aid210, embedding:easynegative, embedding:verybadimagenegative_v1.3, speech bubble, disfigured, extra limbs, low quality, blurred, multiple views, futanari, loli, chibi, gumroad username, patreon username, (extra fingers, watermark, deformed hands, polydactyl, asymmetrical breasts, monochrome, greyscale:1.1)"
},
{
"name": "Negative Pony",
"prompt": "score_4, score_3, score_2, score_1, score_0, censored, monochrome, 3d, signature, watermark, username, speech bubble, embedding:zPDXLxxx"
}
]
} }
] }
+1
View File
@@ -0,0 +1 @@
onnxruntime
+101 -51
View File
@@ -12,6 +12,8 @@ import os
import json import json
import time import time
import torch import torch
import sqlite3
import threading
from server import PromptServer from server import PromptServer
from aiohttp import web from aiohttp import web
@@ -31,66 +33,108 @@ class ConfigReader:
Check if comfy is running in portable mode. Check if comfy is running in portable mode.
""" """
DEFAULT_PATH = os.path.abspath(os.path.join(os.getcwd(), "user/default/comfy.settings.json")) user_directory = folder_paths.get_user_directory()
PORTABLE_PATH = os.path.abspath(os.path.join(os.getcwd(), "ComfyUI/user/default/comfy.settings.json")) SETTINGS_PATH = os.path.join(user_directory, "default/comfy.settings.json")
portable = None
@classmethod @classmethod
def print_sn0w(cls, message, color="\033[0;35m"): def print_sn0w(cls, message, color="\033[0;35m"):
"""Print a message with a specific color prefix.""" """Print a message with a specific color prefix."""
print(f"{color}[sn0w] \033[0m{message}") print(f"{color}[sn0w] \033[0m{message}")
@classmethod
def is_comfy_portable(cls):
"""Check if the application is running in portable mode."""
if cls.portable is not None:
return cls.portable
# Check if default exists
if os.path.isfile(cls.DEFAULT_PATH):
cls.portable = False
ConfigReader.print_sn0w("Running standalone comfy.")
return False
# Check if portable exists
if os.path.isfile(cls.PORTABLE_PATH):
cls.portable = True
ConfigReader.print_sn0w("Running portable comfy.")
return True
# If neither exist
ConfigReader.print_sn0w("Could not find comfy settings.")
return None
@staticmethod @staticmethod
def get_setting(setting_id, default=None): def get_setting(setting_id, default=None):
"""Retrieve a setting value from the configuration file.""" """Retrieve a setting value from the configuration file."""
# Determine the correct path based on the portable attribute
if ConfigReader.portable is None:
ConfigReader.print_sn0w(
f"Local configuration file not found at either {ConfigReader.PORTABLE_PATH} or {ConfigReader.DEFAULT_PATH}.",
"\033[0;33m",
)
return default
path = ConfigReader.PORTABLE_PATH if ConfigReader.portable else ConfigReader.DEFAULT_PATH
# Try to read the settings from the determined path # Try to read the settings from the determined path
try: try:
with open(path, "r", encoding="utf-8") as file: with open(ConfigReader.SETTINGS_PATH, "r", encoding="utf-8") as file:
settings = json.load(file) settings = json.load(file)
return settings.get(setting_id, default) return settings.get(setting_id, default)
except FileNotFoundError:
ConfigReader.print_sn0w(f"Local configuration file not found at {ConfigReader.SETTINGS_PATH}.", "\033[0;33m")
except json.JSONDecodeError:
ConfigReader.print_sn0w(f"Error decoding JSON from {ConfigReader.SETTINGS_PATH}.", "\033[0;31m")
return default
@staticmethod
def set_setting(setting_id: str, value):
"""Set a setting value in the configuration file."""
# Try to read the existing settings from the determined path
path = ConfigReader.SETTINGS_PATH
try:
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as file:
settings = json.load(file)
else:
settings = {} # If the file doesn't exist, start with an empty dict
# If value is None, remove the setting if it exists
if value is None:
if setting_id in settings:
del settings[setting_id]
else:
# Update the setting value
settings[setting_id] = value
# Write the updated settings back to the file
with open(path, "w", encoding="utf-8") as file:
json.dump(settings, file, indent=4)
return True
except FileNotFoundError: except FileNotFoundError:
ConfigReader.print_sn0w(f"Local configuration file not found at {path}.", "\033[0;33m") ConfigReader.print_sn0w(f"Local configuration file not found at {path}.", "\033[0;33m")
except json.JSONDecodeError: except json.JSONDecodeError:
ConfigReader.print_sn0w(f"Error decoding JSON from {path}.", "\033[0;31m") ConfigReader.print_sn0w(f"Error decoding JSON from {path}.", "\033[0;31m")
except IOError as e:
ConfigReader.print_sn0w(f"Error writing to {path}: {e}", "\033[0;31m")
return default return False
@classmethod
def validate_settings(cls):
"""Update to new settings values after frontend update."""
conversion_map = {
"sn0w.CustomLoraLoaders1.5": "sn0w.CustomLoraLoaders",
"sn0w.CustomLoraLoadersXL": "sn0w.CustomLoraLoaders.XL",
"sn0w.CustomLoraLoaders3": "sn0w.CustomLoraLoaders.3",
"sn0w.TextboxColors": "sn0w.TextboxSettings",
"sn0w.TextboxGradientColors": "sn0w.TextboxSettings.GradientColors",
"sn0w.SortLorasBy": "sn0w.LoraSettings.SortLorasBy",
"sn0w.RemoveLoraPath": "sn0w.LoraSettings.RemoveLoraPath",
"sn0w.LoraFolderMinDistance": "sn0w.LoraSettings.LoraFolderMinDistance",
"sn0w.DisableDefaultCharacters": "sn0w.CharacterSettings.DisableDefaultCharacters",
"sn0w.ExcludedRandomCharacters": "sn0w.CharacterSettings.ExcludedRandomCharacters",
"sn0w.SortCharactersBy": "sn0w.CharacterSettings.SortCharactersBy",
"sn0w.TextboxSettings": "SyntaxHighlighting.textbox-colors",
}
path = ConfigReader.SETTINGS_PATH
with open(path, "r", encoding="utf-8") as file:
settings = json.load(file)
# Handle favorites migration
favorite_loras = cls.get_setting("sn0w.FavouriteLoras", [])
favorite_chars = cls.get_setting("sn0w.FavouriteCharacters", [])
if favorite_loras or favorite_chars:
combined_favorites = list(set(favorite_loras + favorite_chars)) # Merge and remove duplicates
cls.set_setting("SyntaxHighlighting.favorites", combined_favorites)
cls.set_setting("sn0w.FavouriteLoras", None) # Remove old setting
cls.set_setting("sn0w.FavouriteCharacters", None) # Remove old setting
# Handle other setting migrations
for setting in settings:
if setting in conversion_map:
setting_value = cls.get_setting(setting)
if setting_value:
cls.set_setting(conversion_map[setting], setting_value)
cls.set_setting(setting, None) # Remove the old setting
# Initialize portable check when the class is defined # Initialize portable check when the class is defined
ConfigReader.is_comfy_portable() ConfigReader.validate_settings()
class Logger: class Logger:
@@ -313,15 +357,22 @@ class Utility:
@classmethod @classmethod
def get_node_output(cls, data, node_id, output_id): def get_node_output(cls, data, node_id, output_id):
"""Get the output of a node from the workflow data.""" """Get the output of a node from the workflow data."""
workflow = data.get("workflow", {}) if not data:
nodes = workflow.get("nodes", []) return None
for node in nodes: try:
if int(node.get("id")) == int(node_id): workflow = data.get("workflow", {})
for output in node.get("outputs", []): nodes = workflow.get("nodes", [])
if int(output["slot_index"]) == int(output_id):
return output for node in nodes:
return None if int(node.get("id")) == int(node_id):
for output in node.get("outputs", []):
if int(output["slot_index"]) == int(output_id):
return output
return None
except Exception as e:
cls.logger.log(f"Error getting node output: {e}", "ERROR")
return None
class AnyType(str): class AnyType(str):
@@ -377,9 +428,7 @@ class MessageHolder:
return int(message.strip()) return int(message.strip())
except ValueError: except ValueError:
cls.logger.log( cls.logger.log(f"failed to parse '{message}' as {'comma separated list of ints' if asList else 'int'}", "ERROR")
f"failed to parse '{message}' as {'comma separated list of ints' if asList else 'int'}", "ERROR"
)
return [1] if asList else 1 return [1] if asList else 1
@@ -441,7 +490,8 @@ async def add_lora_loaders(request):
with open(json_path, "w") as file: with open(json_path, "w") as file:
json.dump(data, file, indent=4) json.dump(data, file, indent=4)
return web.json_response({"message": f"Lora loaders: {new_unique_loaders} added successfully."}) new_unique_loaders_str = "[" + ", ".join(str(loader) for loader in new_unique_loaders) + "]"
return web.json_response({"message": f"Loaders added: {new_unique_loaders_str}"})
except json.JSONDecodeError: except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON format."}, status=400) return web.json_response({"error": "Invalid JSON format."}, status=400)
+195
View File
@@ -0,0 +1,195 @@
"""
Downloads the booru-characters dataset from HuggingFace and builds the local SQLite DB.
"""
import json
import os
import sqlite3
import urllib.request
from datetime import datetime, timezone
from ..sn0w import Logger
DATASET_URL = "https://huggingface.co/datasets/Sn0w123/booru-characters/resolve/main/characters.jsonl"
DATASET_API_URL = "https://huggingface.co/api/datasets/Sn0w123/booru-characters"
logger = Logger()
def _remote_last_modified() -> datetime | None:
"""
Return the last-modified datetime of the dataset.
Queries the HuggingFace Hub API (no file download); falls back to a HEAD
request with ``X-Linked-Last-Modified`` if the API call fails.
Returns ``None`` if both approaches fail.
"""
try:
req = urllib.request.Request(
DATASET_API_URL,
)
with urllib.request.urlopen(req, timeout=15) as response:
data = json.loads(response.read().decode("utf-8"))
raw = data.get("lastModified")
if raw:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception: # pylint: disable=broad-except
pass
logger.log(
"Warning: Could not determine remote last-modified time for dataset, assuming local DB is up to date.",
"WARNING",
)
return None
def is_db_up_to_date(db_path: str) -> bool:
"""
Return ``True`` when the local DB is at least as recent as the remote dataset.
Falls back to ``True`` on any network error so the app still works offline.
"""
if not os.path.exists(db_path):
return False
remote_dt = _remote_last_modified()
if remote_dt is None:
# Can't reach HF – assume local copy is fine
return True
local_mtime = datetime.fromtimestamp(os.path.getmtime(db_path), tz=timezone.utc)
return local_mtime >= remote_dt
def _setup_db(cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
copyright TEXT,
prompt TEXT,
clothing_tags TEXT,
gender TEXT,
post_count INTEGER
)"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS character_relationships (
id INTEGER PRIMARY KEY,
tag_id INTEGER,
related_tag_id INTEGER,
relation_type TEXT,
FOREIGN KEY (tag_id) REFERENCES characters(id),
FOREIGN KEY (related_tag_id) REFERENCES characters(id)
)"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_characters_name ON characters(name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_character_relationships_tag_id ON character_relationships(tag_id)")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_character_relationships_related_tag_id ON character_relationships(related_tag_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_character_relationships_type ON character_relationships(relation_type)"
)
def build_db_from_dataset(db_path: str, logger=None) -> None:
"""
Download ``characters.jsonl`` from HuggingFace and populate the SQLite DB at *db_path*.
The JSONL schema per line:
{
"id": int,
"name": str,
"post_count": int,
"gender": str | null,
"clothing": list[str],
"characteristics": list[str],
"copyright": list[str],
"relationships": {"parents": list[int], "children": list[int], "siblings": list[int]}
}
"""
def log(msg: str, level: str = "INFORMATIONAL") -> None:
if logger is not None:
logger.log(msg, level)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
log("Downloading booru-characters dataset from HuggingFace…")
try:
req = urllib.request.Request(
DATASET_URL,
headers={"User-Agent": "ComfyUI-Sn0w-Scripts/1.0"},
)
with urllib.request.urlopen(req, timeout=120) as response:
raw_bytes = response.read()
except Exception as exc: # pylint: disable=broad-except
raise RuntimeError(f"Failed to download dataset: {exc}") from exc
log("Download complete, parsing dataset…")
lines = raw_bytes.decode("utf-8").splitlines()
records: list[dict] = []
for i, line in enumerate(lines):
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as exc:
log(f"Skipping malformed JSONL line {i}: {exc}", "WARNING")
log(f"Parsed {len(records)} character records, building database…")
# Build in a temp file first, then replace the target path after success
tmp_path = db_path + ".tmp"
if os.path.exists(tmp_path):
os.remove(tmp_path)
conn = sqlite3.connect(tmp_path)
cursor = conn.cursor()
_setup_db(cursor)
# ── Pass 1: insert characters ──────────────────────────────────────────────
for rec in records:
char_id = rec["id"]
name = rec.get("name", "")
post_count = rec.get("post_count", 0)
gender = rec.get("gender") or ""
clothing_tags = ",".join(rec.get("clothing") or [])
prompt = ",".join(rec.get("characteristics") or [])
copyright_tags = ",".join(rec.get("copyright") or [])
cursor.execute(
"""INSERT OR REPLACE INTO characters
(id, name, copyright, prompt, clothing_tags, gender, post_count)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(char_id, name, copyright_tags, prompt, clothing_tags, gender, post_count),
)
# ── Pass 2: insert relationships (IDs are preserved from the dataset) ──────
for rec in records:
tag_id = rec["id"]
rels: dict = rec.get("relationships") or {}
_rel_label_map = {"parents": "parent", "children": "child", "siblings": "sibling"}
for rel_type, related_ids in rels.items():
rel_label = _rel_label_map.get(rel_type)
if rel_label is None:
continue
for related_id in related_ids or []:
cursor.execute(
"""INSERT INTO character_relationships
(tag_id, related_tag_id, relation_type)
VALUES (?, ?, ?)""",
(tag_id, related_id, rel_label),
)
conn.commit()
conn.close()
# Atomically replace old DB with new one
if os.path.exists(db_path):
os.remove(db_path)
os.rename(tmp_path, db_path)
log(f"Database built successfully at '{db_path}' ({len(records)} characters).")
+432
View File
@@ -0,0 +1,432 @@
import re
from ..sn0w import ConfigReader
from .character_select import CharacterSelectNode
GENERIC_TAGS = {
"1girl",
"1boy",
"1other",
"2girls",
"2boys",
"multiple girls",
"multiple boys",
"solo",
"solo focus",
"looking at viewer",
"full body",
"upper body",
"lower body",
"portrait",
"close-up",
"from side",
"from behind",
"from above",
"from below",
"simple background",
"white background",
"transparent background",
"highres",
"absurdres",
"commentary",
"artist name",
"signature",
"watermark",
"sleeveless",
"bare shoulders",
"open clothes",
"collarbone",
}
COLOR_WORDS = {
"black",
"white",
"red",
"blue",
"green",
"yellow",
"orange",
"purple",
"pink",
"brown",
"grey",
"gray",
"blonde",
"blond",
"silver",
"aqua",
"cyan",
"teal",
"magenta",
"maroon",
"navy",
"violet",
"indigo",
"turquoise",
"lavender",
"gold",
"golden",
"copper",
"scarlet",
"crimson",
"emerald",
"azure",
"multicolored",
"streaked",
"gradient",
"rainbow",
"dark",
"light",
"pale",
}
ACCESSORY_HINTS = (
"ornament",
"hairband",
"hairclip",
"hair clip",
"headband",
"hair bow",
"ribbon",
"bow",
"earrings",
"jewelry",
"glasses",
"headphones",
"hat",
"cap",
"mask",
"crown",
"tiara",
"veil",
)
HAIR_LENGTHS = {
"short hair": "short",
"medium hair": "medium",
"long hair": "long",
"very long hair": "very long",
"absurdly long hair": "absurdly long",
}
HAIR_TEXTURES = {
"curly hair": "curly",
"wavy hair": "wavy",
"straight hair": "straight",
"messy hair": "messy",
"spiked hair": "spiked",
"drill hair": "drill",
"flipped hair": "flipped",
}
HAIR_STYLES = {
"ponytail": "tied in a ponytail",
"side ponytail": "tied in a side ponytail",
"twintails": "tied in twintails",
"braid": "tied in a braid",
"twin braids": "tied in twin braids",
"hair bun": "tied in a bun",
"double bun": "tied in double buns",
"hair up": "tied up",
"bob cut": "cut in a bob",
"hime cut": "with a hime cut",
}
SUBJECT_RE = re.compile(
r"\b(?P<article>a|an|the)\s+"
r"(?P<modifiers>(?:(?!(?:a|an|the|of|with|in|on|at|by|and|or)\b)[a-z][\w'-]*\s+){0,3})"
r"(?P<noun>girl|boy|woman|man|person|character|figure|lady|guy|child|teenager|adult|princess|knight|warrior|witch|soldier)\b",
re.IGNORECASE,
)
GERUND_RE = re.compile(r"[A-Za-z]+ing\b")
GENERIC_TAG_RE = re.compile(r"\d+(?:girl|boy|other)s?$")
PLACEHOLDER_RE = re.compile(r"\{\{\s*char([1-4])\s*\}\}", re.IGNORECASE)
CHARACTER_SLOTS = 4
SERIES_PARTICLES = {
"no",
"to",
"wa",
"ga",
"ni",
"de",
"o",
"e",
"ya",
"na",
"mo",
"ka",
"wo",
"of",
"the",
"and",
"in",
"on",
"at",
"for",
"a",
"an",
}
class CharacterPromptInjectNode:
"""
Injects up to four selected characters into a natural language prompt.
"""
db = CharacterSelectNode.db
@classmethod
def INPUT_TYPES(cls):
character_names = ["None"] + cls.db.get_character_names(
include_default=not ConfigReader.get_setting("sn0w.CharacterSettings.DisableDefaultCharacters", False),
)
required = {"prompt": ("STRING", {"default": "", "multiline": True})}
for index in range(1, CHARACTER_SLOTS + 1):
required[f"character_{index}"] = (character_names + ["SN0W_CHARACTER_SELECTOR"],)
return {
"required": required,
}
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("PROMPT",)
FUNCTION = "inject_character"
CATEGORY = "sn0w"
def inject_character(self, prompt, character_1, character_2, character_3, character_4):
prompt = (prompt or "").strip()
slot_values = (character_1, character_2, character_3, character_4)
characters = [self._resolve_character(character) for character in slot_values]
if PLACEHOLDER_RE.search(prompt):
return (self._replace_placeholders(prompt, characters),)
character_data = characters[0]
if character_data is None:
return (prompt,)
name, series, tag_text, gender = character_data
if self._mentions(prompt, name):
return (prompt,)
label = self._label(name, series)
features = self._features(self._parse_tags(tag_text), prompt)
if not prompt:
return (self._full_description(label, gender, features)[0],)
match = SUBJECT_RE.search(prompt)
if match:
return (self._inject_at_subject(prompt, match, name, label, features, gender),)
return (self._inject_at_end(prompt, label, features),)
def _replace_placeholders(self, prompt, characters):
described = set()
def replace(match):
index = int(match.group(1)) - 1
character_data = characters[index]
if character_data is None:
return match.group(0)
name, series, tag_text, gender = character_data
if name.lower() in described:
return name
after = prompt[match.end() :]
if re.match(r"\s*['’]s\b", after):
return name
described.add(name.lower())
label = self._label(name, series)
features = self._features(self._parse_tags(tag_text), prompt)
description, is_appositive = self._full_description(label, gender, features)
if is_appositive and after.strip() and not re.match(r"\s*[,.;:!?\"”’)\]]", after):
description += ","
return description
return PLACEHOLDER_RE.sub(replace, prompt)
def _full_description(self, label, gender, features):
subject = {"1girl": "a girl", "1boy": "a boy"}.get(gender, "")
if not subject:
return self._describe(label, features), False
return f"{label}, {self._describe(subject, features)}", True
def _label(self, name, series):
series = self._title_series(series)
if not series or series.lower() in ("original", "custom"):
return name
return f"{name} from {series}"
def _title_series(self, series):
words = series.replace("_", " ").split()
return " ".join(
word.lower() if index and word.lower() in SERIES_PARTICLES else word[:1].upper() + word[1:]
for index, word in enumerate(words)
)
def _resolve_character(self, character):
if character in (None, "", "None", "SN0W_CHARACTER_SELECTOR"):
return None
raw_name = self._clean_name(character)
if not raw_name:
return None
db_character = self.db.get_character_by_name(raw_name)
if db_character is None:
return raw_name.title(), "", "", ""
name = re.sub(r"\s*\([^)]*\)\s*$", "", db_character.name).strip().title()
series = db_character.copyright[0] if db_character.copyright else ""
return name, series, ", ".join(db_character.prompt), db_character.gender
def _inject_at_subject(self, prompt, match, name, label, features, gender):
after = prompt[match.end() :]
if after.lstrip().startswith(("'", "’")):
return prompt[: match.start()] + name + after
article = match.group("article").lower()
modifiers = re.sub(r"\s+", " ", match.group("modifiers")).strip()
noun = self._adjust_noun(match.group("noun").lower(), gender)
subject = " ".join(part for part in (article, modifiers, noun) if part)
description = self._describe(subject, features)
comma = ""
if after.strip() and not re.match(r"\s*[,.;:!?]", after):
comma = ","
return prompt[: match.start()] + f"{label}, {description}{comma}" + after
def _inject_at_end(self, prompt, label, features):
description = self._describe(label, features)
if GERUND_RE.match(prompt):
return f"{description}, {prompt}"
body = prompt.rstrip().rstrip(",;:")
ending = ""
match = re.search(r"([.!?])\s*$", body)
if match:
body = body[: match.start()]
ending = match.group(1)
return f"{body}, featuring {description}{ending}"
def _describe(self, subject, features):
if not features:
return subject
return f"{subject} with {self._join(features)}"
def _features(self, tags, prompt):
prompt_text = prompt.lower().replace("_", " ")
hair = {"color": "", "length": "", "texture": "", "style": "", "details": []}
eyes = ""
other = []
for tag in tags:
if self._mentions(prompt_text, tag):
continue
lowered = tag.lower()
if self._is_accessory(lowered):
continue
if self._is_hair(lowered):
if lowered in HAIR_STYLES:
hair["style"] = hair["style"] or HAIR_STYLES[lowered]
elif lowered in HAIR_LENGTHS:
hair["length"] = hair["length"] or HAIR_LENGTHS[lowered]
elif lowered in HAIR_TEXTURES:
hair["texture"] = hair["texture"] or HAIR_TEXTURES[lowered]
elif self._has_color(tag):
hair["color"] = hair["color"] or lowered.replace("hair", "").strip()
else:
hair["details"].append(tag)
elif "eyes" in lowered:
eyes = eyes or tag
else:
other.append(tag)
parts = []
hair_phrase = self._hair_phrase(hair)
if hair_phrase:
parts.append(hair_phrase)
if eyes:
parts.append(eyes)
parts.extend(other)
return parts
def _hair_phrase(self, hair):
base = " ".join(part for part in (hair["length"], hair["color"], hair["texture"]) if part)
if base:
base += " hair"
phrase = base
if hair["style"]:
phrase = f"{phrase} {hair['style']}" if phrase else f"hair {hair['style']}"
if hair["details"]:
details = self._join(hair["details"])
phrase = f"{phrase} with {details}" if phrase else details
return phrase
def _is_hair(self, lowered):
return "hair" in lowered or lowered in HAIR_STYLES
def _is_accessory(self, lowered):
return bool(re.search(rf"\b(?:{'|'.join(ACCESSORY_HINTS)})\b", lowered))
def _parse_tags(self, tag_text):
tags = []
seen = set()
for tag in tag_text.split(","):
tag = tag.replace("\\", "")
tag = re.sub(r":\s*[-+]?[\d.]+\)?\s*$", "", tag).strip()
tag = tag.strip("()").strip()
tag = re.sub(r"\s+", " ", tag.replace("_", " "))
lowered = tag.lower()
if not tag or lowered in seen or lowered in GENERIC_TAGS or GENERIC_TAG_RE.fullmatch(lowered):
continue
seen.add(lowered)
tags.append(tag)
return tags
def _clean_name(self, value):
if not value:
return ""
# Widget values are "copyright|name" or "copyright|parent|child"; the selector's
# CHARACTER NAME output is "(name, copyright:strength)".
name = str(value).split("|")[-1].replace("\\", "").strip()
name = name.split(",")[0].strip()
name = re.sub(r":\s*[-+]?[\d.]+\s*\)\s*$", "", name).strip()
if name.startswith("("):
name = name[1:].strip()
return name.replace("_", " ")
def _adjust_noun(self, noun, gender):
if gender == "1girl":
return {"boy": "girl", "man": "woman", "guy": "girl"}.get(noun, noun)
if gender == "1boy":
return {"girl": "boy", "woman": "man", "lady": "man"}.get(noun, noun)
return noun
def _has_color(self, tag):
return bool(set(re.findall(r"[a-z]+", tag.lower())) & COLOR_WORDS)
def _mentions(self, text, phrase):
return bool(re.search(rf"(?<!\w){re.escape(phrase.lower())}(?!\w)", text.lower()))
def _join(self, items):
if len(items) == 1:
return items[0]
if len(items) == 2:
return f"{items[0]} and {items[1]}"
return ", ".join(items[:-1]) + f", and {items[-1]}"
+646 -123
View File
@@ -1,23 +1,602 @@
import os
import json import json
import os
import random import random
import sqlite3
import threading
from typing import TypedDict
from aiohttp import web from ..sn0w import ConfigReader, Logger
from .build_characters_db import build_db_from_dataset, is_db_up_to_date
from ..sn0w import ConfigReader, Logger, Utility, MessageHolder
routes = MessageHolder.routes
API_PREFIX = MessageHolder.API_PREFIX
# File paths
CHARACTER_FILE_PATH = "web/settings/characters.json"
CUSTOM_CHARACTER_FILE_PATH = "web/settings/custom_characters.json"
@routes.post(f"{API_PREFIX}/update_characters") class SeriesInfo(TypedDict):
async def handle_update_characters(request): series: str
CharacterSelectNode.initialize() characters: list[str]
return web.json_response({"status": "ok"})
class Character:
def __init__(self, name: str, prompt: str, copyright: str, clothing: str = "", gender: str = "") -> None:
self.name = name
self.prompt = [tag.strip().replace("_", " ") for tag in prompt.split(",") if tag.strip()]
self.copyright = [tag.strip().replace("_", " ") for tag in copyright.split(",") if tag.strip()]
self.clothing = [tag.strip().replace("_", " ") for tag in clothing.split(",") if tag.strip()]
self.gender = gender.strip().replace("_", " ")
def __str__(self) -> str:
return f"{self.name}, [{', '.join(self.prompt)}], [{', '.join(self.copyright)}], [{', '.join(self.clothing)}], {self.gender}"
class CharacterDB:
logger = Logger()
last_auto_hide = {"count": 0, "characters": [], "series": []}
def __init__(self):
self.db_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "web", "characters", "characters.db"
)
self.custom_characters_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"web",
"settings",
"custom_characters.json",
)
self.visible_series_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"web",
"settings",
"visible_series.json",
)
self.hidden_characters_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"web",
"settings",
"hidden_characters.json",
)
self.CHAR_DELIMITER = "|"
old_series_map = None
hidden_before = None
if not is_db_up_to_date(self.db_path):
try:
if os.path.exists(self.db_path):
old_series_map = self._read_series_map(self.db_path)
hidden_before = self.load_hidden_characters()
if not os.path.exists(self.db_path):
self.logger.log(
"characters.db not found — downloading and building from HuggingFace dataset…", "INFORMATIONAL"
)
else:
self.logger.log(
"characters.db is outdated — re-downloading from HuggingFace dataset…", "INFORMATIONAL"
)
build_db_from_dataset(self.db_path, logger=self.logger)
except Exception as exc: # pylint: disable=broad-except
self.logger.log(f"Failed to build characters database: {exc}", "ERROR")
self.conn = sqlite3.connect(self.db_path, check_same_thread=False) # Allow multi-thread access
self.cursor = self.conn.cursor()
self.lock = threading.Lock() # Add lock for thread-safety
if old_series_map is not None:
try:
self._auto_hide_new_characters(old_series_map, hidden_before)
except Exception as exc: # pylint: disable=broad-except
self.logger.log(f"Failed to auto-hide new characters: {exc}", "WARNING")
# Validate custom_characters.json structure on initialization
is_valid, error_msg = self.validate_custom_characters_structure()
if not is_valid:
self.logger.log(f"Warning: custom_characters.json validation failed: {error_msg}", "WARNING")
def build_char_string(self, names_only: bool, copyright: str, *tags: str) -> str:
"""
Builds a character string by joining the processed copyright and tags with the character delimiter.
"""
if names_only:
return self.process_tag(tags[-1]) if tags else self.process_tag(copyright)
processed_parts = [self.process_tag(copyright)] + [self.process_tag(tag) for tag in tags]
return self.CHAR_DELIMITER.join(processed_parts)
def process_tag(self, name: str) -> str:
return name.replace("_", " ")
def get_character_names(self, include_default=True, names_only=False) -> list[str]:
characters = []
if include_default:
characters.extend(self.get_default_character_names(names_only=names_only))
characters.extend(self.get_custom_character_names(names_only=names_only))
return characters
def get_character_by_name(self, name: str) -> Character | None:
if not name:
return None
normalized = name.strip().lower()
if not normalized:
return None
custom_character = self._get_custom_character_by_name(normalized)
if custom_character:
return custom_character
return self._get_default_character_by_name(normalized)
def _get_custom_character_by_name(self, normalized_name: str) -> Character | None:
if not os.path.exists(self.custom_characters_path):
return None
try:
with open(self.custom_characters_path, "r", encoding="utf-8") as f:
custom_list = json.load(f)
except (json.JSONDecodeError, IOError):
return None
for char in custom_list:
raw_name = str(char.get("name", "")).strip()
if raw_name.lower() != normalized_name:
continue
return Character(
name=raw_name,
prompt=str(char.get("prompt", "")),
copyright=str(char.get("copyright", "")),
clothing=str(char.get("clothing", "")),
gender=str(char.get("gender", "")),
)
return None
def _get_default_character_by_name(self, normalized_name: str) -> Character | None:
with self.lock:
self.cursor.execute(
"""
SELECT name, prompt, copyright, clothing_tags, gender
FROM characters
WHERE lower(name) = ?
OR lower(replace(name, '_', ' ')) = ?
LIMIT 1
""",
(normalized_name, normalized_name),
)
row = self.cursor.fetchone()
if not row:
return None
db_name, prompt, copyright_text, clothing, gender = row
return Character(
name=self.process_tag(db_name),
prompt=prompt or "",
copyright=copyright_text or "",
clothing=clothing or "",
gender=gender or "",
)
def get_custom_character_names(self, names_only=False) -> list[str]:
if not os.path.exists(self.custom_characters_path):
return []
hidden = self.load_hidden_characters()
characters = []
try:
with open(self.custom_characters_path, "r", encoding="utf-8") as f:
custom_list = json.load(f)
for char in custom_list:
raw_name = char.get("name", "").strip()
name = raw_name.lower()
copyright = char.get("copyright", "custom").split(",")[0].strip().lower()
if not raw_name:
continue
if hidden is not None and name in hidden:
continue
characters.append(self.build_char_string(names_only, copyright, raw_name))
except (json.JSONDecodeError, IOError):
return []
return characters
def validate_custom_characters_structure(self) -> tuple[bool, str]:
"""
Validates the structure of custom_characters.json.
Returns (True, "") if valid, else (False, error_message).
"""
if not os.path.exists(self.custom_characters_path):
return False, "File does not exist"
try:
with open(self.custom_characters_path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
return False, f"Invalid JSON: {e}"
except IOError as e:
return False, f"IO Error: {e}"
if not isinstance(data, list):
return False, "Root must be a list of objects"
for i, item in enumerate(data):
if not isinstance(item, dict):
return False, f"Item {i} is not a dictionary"
required_keys = ["name", "prompt", "copyright"]
for key in required_keys:
if key not in item:
return False, f"Item {i} missing required key '{key}'"
if not isinstance(item[key], str):
return False, f"Item {i} key '{key}' must be a string"
return True, ""
@staticmethod
def _build_copyright_picker(all_characters: list[tuple[int, str, str]]):
copyright_counts = {}
for _, _, copyright_text in all_characters:
if not copyright_text:
continue
for part in copyright_text.split(","):
token = part.strip().lower()
if not token:
continue
copyright_counts[token] = copyright_counts.get(token, 0) + 1
def pick_popular_copyright(copyright_text: str) -> str:
if not copyright_text:
return ""
candidates = []
for part in copyright_text.split(","):
token = part.strip().lower()
if token:
candidates.append(token)
if not candidates:
return ""
candidates.sort(key=lambda item: (-copyright_counts.get(item, 0), item))
return candidates[0]
return pick_popular_copyright
def load_visible_series(self) -> set[str] | None:
"""
Load the list of visible series from visible_series.json.
Returns None if file doesn't exist (meaning show all).
Returns empty set if file exists but is empty (meaning show none).
"""
if not os.path.exists(self.visible_series_path):
return None
try:
with open(self.visible_series_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
return None
return {s.strip().lower() for s in data if isinstance(s, str) and s.strip()}
except (json.JSONDecodeError, IOError):
return None
def load_hidden_characters(self) -> set[str] | None:
"""
Load the list of hidden characters from hidden_characters.json.
Returns None if file doesn't exist (meaning hide none).
Returns an empty set if the file exists but is an empty list (meaning no characters currently hidden).
"""
if not os.path.exists(self.hidden_characters_path):
return None
try:
with open(self.hidden_characters_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
return None
# Normalize to processed, lowercased names for comparison
return {self.process_tag(s).strip().lower() for s in data if isinstance(s, str) and s.strip()}
except (json.JSONDecodeError, IOError):
return None
def get_hidden_characters(self) -> list[str] | None:
"""Return a sorted list of currently hidden characters, or None if no hidden file exists."""
hidden = self.load_hidden_characters()
if hidden is None:
return None
return sorted(hidden)
def set_hidden_characters(self, characters_list: list[str] | None) -> bool:
"""
Set the list of hidden characters.
Pass None or empty list to remove the filter file (hide none).
Returns True on success, False on failure.
"""
if characters_list is None or len(characters_list) == 0:
# Delete the file to remove hidden filter
if os.path.exists(self.hidden_characters_path):
try:
os.remove(self.hidden_characters_path)
return True
except IOError:
return False
return True
try:
normalized = sorted(
set(self.process_tag(s).strip().lower() for s in characters_list if isinstance(s, str) and s.strip())
)
with open(self.hidden_characters_path, "w", encoding="utf-8") as f:
json.dump(normalized, f, indent=2)
return True
except IOError:
return False
def get_visible_series(self) -> list[str] | None:
"""
Get the current list of visible series.
Returns None if all series are visible (no filter applied).
"""
visible = self.load_visible_series()
if visible is None:
return None
return sorted(visible)
def set_visible_series(self, series_list: list[str] | None) -> bool:
"""
Set the list of visible series.
Pass None or empty list to show all series (removes the filter file).
Returns True on success, False on failure.
"""
if series_list is None or len(series_list) == 0:
# Delete the file to show all series
if os.path.exists(self.visible_series_path):
try:
os.remove(self.visible_series_path)
return True
except IOError:
return False
return True
try:
normalized = sorted(set(s.strip().lower() for s in series_list if s.strip()))
with open(self.visible_series_path, "w", encoding="utf-8") as f:
json.dump(normalized, f, indent=2)
return True
except IOError:
return False
def _read_series_map(self, db_path: str) -> dict[str, str] | None:
"""Map normalized character name -> selected series for the DB at *db_path*."""
try:
conn = sqlite3.connect(db_path)
try:
rows = conn.execute("SELECT id, name, copyright FROM characters").fetchall()
finally:
conn.close()
except (sqlite3.Error, IOError, OSError):
return None
pick = self._build_copyright_picker(rows)
series_map: dict[str, str] = {}
for _, name, copyright_text in rows:
if not name:
continue
normalized = self.process_tag(name).strip().lower()
if not normalized:
continue
series_map[normalized] = pick(copyright_text)
return series_map
def _auto_hide_new_characters(
self, old_series_map: dict[str, str] | None, hidden_before: set[str] | None
) -> None:
"""Hide newly added characters in series where the user already hid something."""
if old_series_map is None or not hidden_before:
return
self.cursor.execute("SELECT id, name, copyright FROM characters")
rows = self.cursor.fetchall()
pick = self._build_copyright_picker(rows)
new_series_map: dict[str, str] = {}
for _, name, copyright_text in rows:
if not name:
continue
normalized = self.process_tag(name).strip().lower()
if normalized:
new_series_map[normalized] = pick(copyright_text)
added = set(new_series_map) - set(old_series_map)
if not added:
return
hidden_series = {
series
for name, series in list(old_series_map.items()) + list(new_series_map.items())
if series and name in hidden_before
}
if not hidden_series:
return
visible_series = self.load_visible_series()
to_hide = [
name
for name in added
if new_series_map.get(name) in hidden_series
and (visible_series is None or new_series_map.get(name) in visible_series)
]
if not to_hide:
return
merged = set(hidden_before) | set(to_hide)
if self.set_hidden_characters(sorted(merged)):
type(self).last_auto_hide = {
"count": len(to_hide),
"characters": sorted(to_hide),
"series": sorted(hidden_series),
}
self.logger.log(f"Auto-hid {len(to_hide)} new character(s) in series with hidden characters.", "INFORMATIONAL")
def get_default_character_names(self, names_only=False) -> list[str]:
with self.lock:
# Load visible series and hidden characters filters early
visible_series = self.load_visible_series()
hidden_characters = self.load_hidden_characters()
# Fetch all characters once
self.cursor.execute("SELECT id, name, copyright FROM characters")
all_characters = self.cursor.fetchall()
pick_popular_copyright = self._build_copyright_picker(all_characters)
# Fetch all parent-child relationships in one query
self.cursor.execute(
"""
SELECT r.related_tag_id, c.id, c.name
FROM character_relationships r
INNER JOIN characters c ON r.tag_id = c.id
WHERE r.relation_type = 'parent'
"""
)
relationship_rows = self.cursor.fetchall()
# Build a map: parent_id -> list of (child_id, child_name)
children_map = {}
child_ids = set()
for parent_id, child_id, child_name in relationship_rows:
children_map.setdefault(parent_id, []).append((child_id, child_name))
child_ids.add(child_id)
# Process all characters, skipping those that are children (not roots)
formatted = []
for char_id, name, copyright_text in all_characters:
if char_id in child_ids:
continue # This is a child, not a root character
# Respect hidden characters filter (match against processed name)
processed_root = self.process_tag(name).strip().lower()
if hidden_characters is not None and processed_root in hidden_characters:
continue
selected_copyright = pick_popular_copyright(copyright_text)
if not selected_copyright:
continue
# Filter by visible series if filter is active
if visible_series is not None and selected_copyright not in visible_series:
continue
children = children_map.get(char_id, [])
added_any_child = False
if children:
for _, child_name in children:
processed_child = self.process_tag(child_name).strip().lower()
if hidden_characters is not None and processed_child in hidden_characters:
continue
formatted.append(self.build_char_string(names_only, selected_copyright, name, child_name))
added_any_child = True
# If no children were added (all hidden), add the parent alone
if not added_any_child:
formatted.append(self.build_char_string(names_only, selected_copyright, name))
else:
formatted.append(self.build_char_string(names_only, selected_copyright, name))
return formatted
def get_all_series(self, remove_hidden_characters=True) -> list[SeriesInfo]:
"""
Return a merged list of series from both the default DB and `custom_characters.json`.
Respects `visible_series.json` and `hidden_characters.json` filters when applicable.
"""
with self.lock:
# Load default characters from DB
self.cursor.execute("SELECT id, name, copyright FROM characters")
all_characters = self.cursor.fetchall()
pick_popular_copyright = self._build_copyright_picker(all_characters)
hidden_characters = self.load_hidden_characters() if remove_hidden_characters else None
visible_series = self.load_visible_series()
series_map: dict[str, list[str]] = {}
# Process default (DB) characters
for _, name, copyright_text in all_characters:
processed_name = self.process_tag(name).strip().lower()
if hidden_characters is not None and processed_name in hidden_characters:
continue
selected_copyright = pick_popular_copyright(copyright_text)
if not selected_copyright:
continue
if visible_series is not None and selected_copyright not in visible_series:
continue
series_map.setdefault(selected_copyright, []).append(self.process_tag(name))
# Merge custom characters from `custom_characters.json` (if present)
if os.path.exists(self.custom_characters_path):
try:
with open(self.custom_characters_path, "r", encoding="utf-8") as f:
custom_list = json.load(f)
if isinstance(custom_list, list):
for item in custom_list:
if not isinstance(item, dict):
continue
raw_name = item.get("name", "")
if not isinstance(raw_name, str) or not raw_name.strip():
continue
# Use provided copyright or fall back to "custom" to ensure it appears in the map
copyright_field = str(item.get("copyright", "custom")).strip().lower() or "custom"
series_map.setdefault(copyright_field, []).append(self.process_tag(raw_name))
except (json.JSONDecodeError, IOError):
# Ignore custom file errors — behave like file not present
pass
# Remove duplicates while preserving insertion order for each series
for series_name, chars in list(series_map.items()):
series_map[series_name] = list(dict.fromkeys(chars))
return [
{"series": series_name, "characters": characters}
for series_name, characters in sorted(series_map.items())
]
def get_all_series_with_post_counts(self, remove_hidden_characters=True):
with self.lock:
self.cursor.execute("SELECT id, name, copyright, post_count FROM characters")
all_characters = self.cursor.fetchall()
# Extract only the first three elements (id, name, copyright) for the picker
all_characters_for_picker = [(id, name, copyright) for id, name, copyright, _ in all_characters]
pick_popular_copyright = self._build_copyright_picker(all_characters_for_picker)
hidden_characters = self.load_hidden_characters() if remove_hidden_characters else None
series_map = {}
for _, name, copyright_text, post_count in all_characters:
processed_name = self.process_tag(name).strip().lower()
if hidden_characters is not None and processed_name in hidden_characters:
continue
selected_copyright = pick_popular_copyright(copyright_text)
if not selected_copyright:
continue
series_map.setdefault(selected_copyright, []).append(
{"name": self.process_tag(name), "post_count": int(post_count)}
)
return [
{"series": series_name, "characters": characters}
for series_name, characters in sorted(series_map.items())
]
def get_random_character(self, valid_characters: list[str] | None = None) -> Character | None:
"""
Get a random character from the available characters.
Optionally, provide a list of valid character strings to choose from.
The valid_characters should be in the format returned by get_character_names().
Returns a Character object or None if no characters are available.
"""
names = self.get_character_names(include_default=True, names_only=True)
if valid_characters is not None:
names = [n for n in names if n in valid_characters]
if not names:
return None
selected = random.choice(names)
return self.get_character_by_name(selected)
class CharacterSelectNode: class CharacterSelectNode:
@@ -25,22 +604,13 @@ class CharacterSelectNode:
Node that lets a user select a character and then returns the character and character prompt Node that lets a user select a character and then returns the character and character prompt
""" """
cached_default_character_setting = ConfigReader.get_setting(
"sn0w.CharacterSettings.DisableDefaultCharacters", False
)
character_dict = {} character_dict = {}
final_character_dict = {}
final_characters = []
cached_sorting_setting = ConfigReader.get_setting("sn0w.SortCharactersBy", "alphabetical")
cached_default_character_setting = ConfigReader.get_setting("sn0w.DisableDefaultCharacters", False)
last_character = "" last_character = ""
logger = Logger() logger = Logger()
db = CharacterDB()
@classmethod
def initialize(cls):
dir_path = os.path.dirname(os.path.realpath(__file__))
if os.path.basename(dir_path) == "src":
dir_path = os.path.dirname(dir_path)
cls.load_characters(dir_path)
@classmethod @classmethod
def get_base_dir(cls): def get_base_dir(cls):
@@ -49,72 +619,12 @@ class CharacterSelectNode:
dir_path = os.path.dirname(dir_path) dir_path = os.path.dirname(dir_path)
return dir_path return dir_path
@classmethod
def check_initialize(cls):
if not cls.final_characters or cls.cached_default_character_setting != ConfigReader.get_setting(
"sn0w.DisableDefaultCharacters", False
):
cls.cached_default_character_setting = ConfigReader.get_setting("sn0w.DisableDefaultCharacters", False)
cls.initialize()
elif cls.cached_sorting_setting != ConfigReader.get_setting("sn0w.SortCharactersBy", "alphabetical"):
cls.sort_characters()
@classmethod
def load_characters(cls, dir_path):
character_data = []
if not cls.cached_default_character_setting:
json_path = os.path.join(dir_path, CHARACTER_FILE_PATH)
with open(json_path, "r", encoding="utf-8") as file:
character_data = json.load(file)
custom_json_path = os.path.join(dir_path, CUSTOM_CHARACTER_FILE_PATH)
if not os.path.exists(custom_json_path):
cls.logger.log(
f"Custom character file doesn't exist. Please create the json file at: {custom_json_path}", "WARNING"
)
else:
with open(custom_json_path, "r", encoding="utf-8") as file:
custom_character_data = json.load(file)
for custom_character in custom_character_data:
for character in character_data:
if custom_character["name"] == character["name"]:
character["associated_string"] += ", " + custom_character["associated_string"]
character["prompt"] += ", " + custom_character["prompt"]
break
else:
character_data.append(custom_character)
cls.character_dict = {character["name"]: character for character in character_data}
cls.sort_characters(True)
@staticmethod
def extract_series_name(character_name):
series = character_name.split("(")[-1].split(")")[0].strip()
if character_name == series:
Logger().log(f"{character_name} has no series name.", "DEBUG")
return series
@classmethod
def sort_characters(cls, force_sort=False):
current_sorting_setting = ConfigReader.get_setting("sn0w.SortCharactersBy", "alphabetical")
if current_sorting_setting != cls.cached_sorting_setting or force_sort:
if current_sorting_setting == "series":
cls.final_character_dict = {
name: cls.character_dict[name] for name in sorted(cls.character_dict, key=cls.extract_series_name)
}
if current_sorting_setting == "alphabetical":
cls.final_character_dict = {name: cls.character_dict[name] for name in sorted(cls.character_dict)}
cls.cached_sorting_setting = current_sorting_setting
# Put favourite characters on top
cls.final_characters = Utility.put_favourite_on_top("sn0w.FavouriteCharacters", cls.final_character_dict)
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
cls.check_initialize() filtered_character_names = cls.db.get_character_names(
character_names = ["None"] + list(cls.final_characters) include_default=not ConfigReader.get_setting("sn0w.CharacterSettings.DisableDefaultCharacters", False),
)
character_names = ["None"] + filtered_character_names + ["SN0W_CHARACTER_SELECTOR"]
return { return {
"required": { "required": {
"character": (character_names,), "character": (character_names,),
@@ -122,11 +632,24 @@ class CharacterSelectNode:
"FLOAT", "FLOAT",
{"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.05, "round": 0.01}, {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.05, "round": 0.01},
), ),
"character_prompt": ("BOOLEAN", {"default": False}),
"random_character": ("BOOLEAN", {"default": False}), "random_character": ("BOOLEAN", {"default": False}),
}, },
} }
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
character_strength = kwargs.get("character_strength")
if character_strength is not None:
try:
strength_value = float(character_strength)
if not (0.0 <= strength_value <= 100.0):
return False, "Character Strength must be between 0.0 and 100.0"
except ValueError:
return False, "Character Strength must be a valid number"
return True
@classmethod @classmethod
def IS_CHANGED(cls, **kwargs): def IS_CHANGED(cls, **kwargs):
if kwargs["random_character"]: if kwargs["random_character"]:
@@ -137,66 +660,66 @@ class CharacterSelectNode:
FUNCTION = "find_character" FUNCTION = "find_character"
CATEGORY = "sn0w" CATEGORY = "sn0w"
def find_character(self, character, character_strength, character_prompt, random_character): def escape_parentheses(self, text: str) -> str:
"""
Escapes parentheses with a single backslash in the resulting string.
"""
return text.replace("(", "\\(").replace(")", "\\)")
def find_character(self, character, character_strength, random_character):
if random_character: if random_character:
char_item = self.select_random_character() char_item = self.select_random_character()
elif character == "None": elif character == "None" or character == "SN0W_CHARACTER_SELECTOR":
return ("", "") return ("", "")
else: else:
char_item = self.final_character_dict.get(character) char_item = self.db.get_character_by_name(character)
self.last_character = char_item self.last_character = char_item.name if char_item else ""
character_name = "" character_name = ""
prompt = "" prompt = ""
if char_item: if char_item:
character_name = char_item["associated_string"] character_name = self.escape_parentheses(f"{char_item.name}, {', '.join(char_item.copyright)}")
prompt = char_item["prompt"] if character_prompt else "" prompt = char_item.prompt
strength_part = f":{character_strength}" if character_strength != 1 else None strength_part = f":{character_strength}" if character_strength != 1 else None
if character_name: if character_name:
if strength_part is not None: if strength_part is not None:
character_name = f"({character_name}{strength_part}), " character_name = f"({character_name}{strength_part})"
else: else:
character_name = f"{character_name}, " character_name = f"{character_name}"
return (character_name, prompt) return (character_name, self.escape_parentheses(", ".join(prompt)))
def select_random_character(self): def select_random_character(self):
# Fetching the exclusion settings # Fetching the exclusion settings
exclude_characters = ConfigReader.get_setting("sn0w.ExcludedRandomCharacters", False) exclude_characters = ConfigReader.get_setting("sn0w.CharacterSettings.ExcludedRandomCharacters", False)
if exclude_characters is False: if exclude_characters is False:
random_character_name = random.choice(list(self.final_character_dict.keys())) char_item = self.db.get_random_character()
char_item = self.final_character_dict[random_character_name] self.logger.log("Random Character: " + str(char_item.name), "INFORMATIONAL")
self.logger.log("Random Character: " + str(char_item["name"]), "INFORMATIONAL")
return char_item return char_item
favourite_characters = ConfigReader.get_setting("sn0w.FavouriteCharacters", []) favourite_characters: list[str] = ConfigReader.get_setting("SyntaxHighlighting.favorites", [])
if favourite_characters == []: if favourite_characters == []:
self.logger.log("No valid characters found based on the specified criteria.", "WARNING") self.logger.log("No valid characters found based on the specified criteria.", "WARNING")
return "" return None
# Filter the characters by excluding the ones in the exclusion list
filtered_characters = {
name: char for name, char in self.final_character_dict.items() if name in favourite_characters
}
# Choosing a random character from the filtered list # Choosing a random character from the filtered list
if filtered_characters: if favourite_characters:
if len(filtered_characters) == 1: if len(favourite_characters) == 1:
random_character_name = random.choice(list(filtered_characters.keys())) char_item = self.db.get_random_character(favourite_characters)
char_item = filtered_characters[random_character_name]
else: else:
while True: tries = 0
random_character_name = random.choice(list(filtered_characters.keys())) while tries < 10: # Avoid infinite loop, try up to 10 times to find a different character
char_item = filtered_characters[random_character_name] tries += 1
if char_item != self.last_character: char_item = self.db.get_random_character(favourite_characters)
if char_item.name != self.last_character:
break break
# Logging the chosen character's name # Logging the chosen character's name
self.logger.log("Random Character: " + str(char_item["name"]), "INFORMATIONAL") self.logger.log("Random Character: " + str(char_item.name), "INFORMATIONAL")
return char_item return char_item
# Handling the case where no characters meet the criteria # Handling the case where no characters meet the criteria
self.logger.log("No valid characters found based on the specified criteria.", "WARNING") self.logger.log("No valid characters found based on the specified criteria.", "WARNING")
return "" return None
+34 -6
View File
@@ -5,13 +5,23 @@ import os
import folder_paths import folder_paths
from ..sn0w import Logger, Utility, ConfigReader from ..sn0w import Logger, Utility, ConfigReader
from .lora_presets import MAX_PRESET_DEPTH, find_preset, normalize_loader_path, presets_for_path
def generate_lora_node_class(lora_type, required_folders=None, combos=1): def generate_lora_node_class(lora_type, required_folders=None, combos=1):
"""Generates a custom lora loader node""" """Generates a custom lora loader node"""
try:
# Get the list of filenames based on the lora_type
folder_paths.get_filename_list(lora_type)
except Exception:
logger = Logger()
logger.log(f"{lora_type} doesn't exist. Please add {lora_type} to extra_model_paths.yaml", "WARNING")
return None
class DynamicLoraNode: class DynamicLoraNode:
logger = Logger() logger = Logger()
LORA_PATH = required_folders
LORA_COMBOS = combos
@classmethod @classmethod
def sort_loras(cls, loras, method="alphabetical"): def sort_loras(cls, loras, method="alphabetical"):
@@ -32,11 +42,13 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1):
# Get the list of filenames based on the lora_type # Get the list of filenames based on the lora_type
loras = folder_paths.get_filename_list(lora_type) loras = folder_paths.get_filename_list(lora_type)
except Exception: except Exception:
cls.logger.log(f"{lora_type} doesnt exist. Please add {lora_type} to extra_model_paths.yaml", "WARNING") cls.logger.log(
f"{lora_type} doesn't exist. Please add {lora_type} to extra_model_paths.yaml", "WARNING"
)
loras = folder_paths.get_filename_list("loras") loras = folder_paths.get_filename_list("loras")
sort_by = ConfigReader.get_setting("sn0w.SortLorasBy", "alphabetical") sort_by = ConfigReader.get_setting("sn0w.LoraSettings.SortLorasBy", "alphabetical")
remove_paths = ConfigReader.get_setting("sn0w.RemoveLoraPath", False) remove_paths = ConfigReader.get_setting("sn0w.LoraSettings.RemoveLoraPath", False)
if not isinstance(remove_paths, bool): if not isinstance(remove_paths, bool):
remove_paths = bool(remove_paths) remove_paths = bool(remove_paths)
sorted_loras = cls.sort_loras(loras, sort_by) sorted_loras = cls.sort_loras(loras, sort_by)
@@ -61,6 +73,11 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1):
filtered_sorted_loras = Utility.put_favourite_on_top("sn0w.FavouriteLoras", filtered_sorted_loras) filtered_sorted_loras = Utility.put_favourite_on_top("sn0w.FavouriteLoras", filtered_sorted_loras)
lora_options = ["None"] + filtered_sorted_loras
if required_folders is not None:
loader_path = normalize_loader_path(required_folders)
lora_options += [preset["name"] for preset in presets_for_path(loader_path)]
inputs = { inputs = {
"required": { "required": {
"model": ("MODEL",), "model": ("MODEL",),
@@ -68,7 +85,7 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1):
}, },
} }
if combos == 1: if combos == 1:
inputs["required"]["lora"] = (["None"] + filtered_sorted_loras,) inputs["required"]["lora"] = (lora_options,)
inputs["required"]["lora_strength"] = ( inputs["required"]["lora_strength"] = (
"FLOAT", "FLOAT",
{"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}, {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01},
@@ -78,7 +95,7 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1):
else: else:
for idx in range(combos): for idx in range(combos):
suffix = chr(97 + idx) # 'a', 'b', 'c', etc. suffix = chr(97 + idx) # 'a', 'b', 'c', etc.
inputs["required"][f"lora_{suffix}"] = (["None"] + filtered_sorted_loras,) inputs["required"][f"lora_{suffix}"] = (lora_options,)
inputs["required"][f"lora_strength_{suffix}"] = ( inputs["required"][f"lora_strength_{suffix}"] = (
"FLOAT", "FLOAT",
{"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}, {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01},
@@ -92,10 +109,21 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1):
CATEGORY = "sn0w/lora" CATEGORY = "sn0w/lora"
OUTPUT_NODE = True OUTPUT_NODE = True
def load_lora(self, model, clip, lora, lora_strength): def load_lora(self, model, clip, lora, lora_strength, depth=0):
if lora == "None": if lora == "None":
return (model, clip) return (model, clip)
preset = None
if required_folders is not None:
preset = find_preset(normalize_loader_path(required_folders), lora)
if preset is not None:
if depth >= MAX_PRESET_DEPTH:
self.logger.log("Preset expansion too deep, skipping", "WARNING")
return (model, clip)
for entry in preset["loras"]:
model, clip = self.load_lora(model, clip, entry["lora"], entry["strength"] * lora_strength, depth + 1)
return (model, clip)
lora_loader = LoraLoader() lora_loader = LoraLoader()
full_loras_list = folder_paths.get_filename_list("loras") full_loras_list = folder_paths.get_filename_list("loras")
full_lora_path = next((full_path for full_path in full_loras_list if lora in full_path), None) full_lora_path = next((full_path for full_path in full_loras_list if lora in full_path), None)
+220
View File
@@ -0,0 +1,220 @@
import os
import json
import re
import comfy.samplers
import comfy.utils
import numpy as np
from PIL import Image
from nodes import VAEDecode
from ..sn0w import ConfigReader, Logger
from .character_select import CharacterDB, SeriesInfo
from .simple_ksampler import SimpleSamplerCustom
# File paths
IMAGES_FILE = "images.json"
CHARACTER_FILE_PATH = "web/settings/characters.csv"
CUSTOM_CHARACTER_FILE_PATH = "web/settings/custom_characters.json"
class GenerateCharactersNode:
logger = Logger()
db = CharacterDB()
series_list = []
@classmethod
def get_base_dir(cls):
dir_path = os.path.dirname(os.path.realpath(__file__))
if os.path.basename(dir_path) == "src":
dir_path = os.path.dirname(dir_path)
return dir_path
@classmethod
def get_preview_image_path(cls):
path: str = ConfigReader.get_setting("SyntaxHighlighting.preview-image-save-path", "./web/images")
base_dir = cls.get_base_dir()
if path.startswith("./"):
path = os.path.join(base_dir, path[2:])
else:
path = os.path.abspath(path)
return path
@classmethod
def load_characters(cls):
series = cls.db.get_all_series()
series_list_with_counts = []
for series_info in series:
count = len(series_info.get("characters", []))
series_name = series_info.get("series", "")
series_list_with_counts.append(f"{series_name} ({count})")
cls.series_list = ["All"] + series_list_with_counts
@classmethod
def initialize(cls):
cls.json_path = os.path.join(cls.get_preview_image_path(), IMAGES_FILE)
cls.characters_dir = os.path.join(cls.get_preview_image_path(), "characters")
os.makedirs(cls.characters_dir, exist_ok=True)
cls.load_characters()
@classmethod
def INPUT_TYPES(cls):
cls.initialize()
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"vae": ("VAE",),
"series_filter": (cls.series_list, {"default": "All"}),
"include_clothing": ("BOOLEAN", {"default": True}),
"add_noise": ("BOOLEAN", {"default": True}),
"noise_seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler_name": (comfy.samplers.KSampler.SCHEDULERS,),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "round": 0.01}),
"width": ("INT", {"default": 0, "min": 0, "step": 64}),
"height": ("INT", {"default": 0, "min": 0, "step": 64}),
},
"optional": {
"positive": ("STRING", {"default": ""}),
"negative": ("STRING", {"default": ""}),
},
"hidden": {
"extra_info": "EXTRA_PNGINFO",
"id": "UNIQUE_ID",
},
}
@classmethod
def VALIDATE_INPUTS(cls, input_types):
return True
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGES",)
FUNCTION = "generate"
CATEGORY = "sn0w"
@classmethod
def load_existing_images(cls):
try:
with open(cls.json_path, "r", encoding="utf-8") as f:
data = json.load(f)
return [img["filename"] for img in data.get("images", [])]
except Exception as e:
cls.logger.log(f"Error loading images.json: {str(e)}", "WARNING")
return []
@classmethod
def slugify(cls, text):
# Slugify: lowercase, replace non-alphanumeric sequences with '_', strip leading/trailing '_'
return re.sub(r"\W+", "_", text.lower()).strip("_")
def generate(
self,
model,
clip,
vae,
series_filter,
include_clothing,
add_noise,
noise_seed,
steps,
cfg,
sampler_name,
scheduler_name,
denoise,
width,
height,
**kwargs,
):
simple_sampler = SimpleSamplerCustom()
vae_decode = VAEDecode()
series = self.db.get_all_series()
existing_filenames = self.load_existing_images()
orig_positive = kwargs.get("positive", "")
match = re.match(r"^(.*?)\s*\(\d+\)$", series_filter)
if match:
selected_series = match.group(1).strip()
else:
selected_series = series_filter
# Build characters to process based on series filter
characters_to_process = {}
for s in series:
if selected_series == "All" or s["series"] == selected_series:
for char_name in s["characters"]:
char = self.db.get_character_by_name(char_name)
if char:
cleaned_name = self.slugify(char.name)
if cleaned_name not in existing_filenames:
characters_to_process[char.name] = char
else:
self.logger.log(f"Skipping {cleaned_name} - image already exists", "INFO")
total_characters = len(characters_to_process)
if total_characters == 0:
self.logger.log("No new characters to process", "INFO")
return ([],)
pbar = comfy.utils.ProgressBar(total_characters)
self.logger.log(f"Processing {total_characters} characters", "INFO")
images = []
for character_name, char_item in characters_to_process.items():
cleaned_name = self.slugify(char_item.name)
# Skip if image already exists
if cleaned_name in existing_filenames:
self.logger.log(f"Skipping {cleaned_name} - image already exists", "INFO")
continue
self.logger.log(f"Generating image for character: {character_name}", "INFO")
# Build prompt components
char_string = f"({char_item.name}, {', '.join(char_item.copyright)}:1.1), "
char_prompt = ", ".join(char_item.prompt)
char_gender = char_item.gender
clothing_prompt = ", ".join(char_item.clothing) if include_clothing else ""
# Combine prompts
prompt_parts = [
part for part in [char_gender, char_string, char_prompt, clothing_prompt, orig_positive] if part.strip()
]
positive = ", ".join(prompt_parts).strip(", ")
kwargs["positive"] = positive
(image, samples, positive_prompt, negative_prompt) = simple_sampler.sample(
model,
clip,
vae,
add_noise,
noise_seed,
steps,
cfg,
sampler_name,
scheduler_name,
denoise,
width,
height,
**kwargs,
)
if samples is not None:
# Decode the tensor into an image using VAE
decoded_image = vae_decode.decode(vae, samples)[0]
images.append(decoded_image[0])
image_path = os.path.join(self.characters_dir, f"{cleaned_name}.png")
# Convert to PIL image and save
i = 255.0 * decoded_image[0].cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
img.save(image_path)
pbar.update(1)
return (images,)
+90 -59
View File
@@ -12,9 +12,40 @@ class LoadLoraFolderNode:
""" """
logger = Logger() logger = Logger()
_lora_paths_cache = {"full": [], "typed": {}}
@classmethod
def _build_lora_paths_cache(cls):
full_lora_paths = folder_paths.get_filename_list("loras")
typed_lora_paths = {}
folder_map = {
"SD15": "loras_15",
"SDXL": "loras_xl",
"SD3": "loras_3",
"VD": "loras_vd",
}
for model_type, folder_key in folder_map.items():
try:
typed_lora_paths[model_type] = folder_paths.get_filename_list(folder_key)
except Exception:
cls.logger.log(
f'Correct lora folder path for "{folder_key}" doesnt exist. Falling back to the generic loras folder.',
"WARNING",
)
typed_lora_paths[model_type] = full_lora_paths
cls._lora_paths_cache = {
"full": full_lora_paths,
"typed": typed_lora_paths,
}
return cls._lora_paths_cache
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
cls._build_lora_paths_cache()
return { return {
"required": { "required": {
"model": ("MODEL",), "model": ("MODEL",),
@@ -47,6 +78,19 @@ class LoadLoraFolderNode:
def normalize_folder_name(self, folder_name): def normalize_folder_name(self, folder_name):
return folder_name.strip().replace("\\", "/").lower() return folder_name.strip().replace("\\", "/").lower()
def get_path_segments(self, path):
normalized_path = self.normalize_folder_name(os.path.dirname(path))
return [segment for segment in normalized_path.split("/") if segment]
def path_matches_folder(self, folder_name, path):
normalized_folder = self.normalize_folder_name(folder_name)
normalized_path = self.normalize_folder_name(os.path.dirname(path))
if "/" in normalized_folder:
return normalized_path == normalized_folder or normalized_path.startswith(normalized_folder + "/")
return normalized_folder in self.get_path_segments(path)
def parse_folders(self, folder_string): def parse_folders(self, folder_string):
master_folder = None master_folder = None
include_folders = {} include_folders = {}
@@ -78,79 +122,66 @@ class LoadLoraFolderNode:
prompt_parts = [self.clean_string(part) for part in prompt.split(separator)] prompt_parts = [self.clean_string(part) for part in prompt.split(separator)]
model_type = Utility.get_model_type_simple(model) model_type = Utility.get_model_type_simple(model)
# Retrieve and filter Lora file paths cache = self._lora_paths_cache
full_lora_paths = folder_paths.get_filename_list("loras")
try: full_lora_paths = cache["full"]
if model_type == "SD15": filtered_lora_paths = cache["typed"].get(model_type, full_lora_paths)
filtered_lora_paths = folder_paths.get_filename_list("loras_15")
elif model_type == "SDXL":
filtered_lora_paths = folder_paths.get_filename_list("loras_xl")
elif model_type == "SD3":
filtered_lora_paths = folder_paths.get_filename_list("loras_3")
else:
filtered_lora_paths = folder_paths.get_filename_list("loras_vd")
except Exception:
self.logger.log(
f'Correct lora folder path for "{model_type}" doesnt exist. Please add the required lora path to extra_model_paths.yaml',
"WARNING",
)
filtered_lora_paths = full_lora_paths
master_folder, include_folders, exclude_folders = self.parse_folders(folders) master_folder, include_folders, exclude_folders = self.parse_folders(folders)
# Include only paths in the master folder, if specified # Include only paths in the master folder, if specified
if master_folder: if master_folder:
filtered_lora_paths = [path for path in filtered_lora_paths if master_folder in self.normalize_folder_name(path)] filtered_lora_paths = [
path for path in filtered_lora_paths if self.path_matches_folder(master_folder, path)
]
# Further filter paths to include and exclude specific folders # Filter paths to include and exclude specific folders in a single pass
lora_paths = [path for path in filtered_lora_paths if any(folder in self.normalize_folder_name(path) for folder in include_folders)] lora_paths = [
lora_paths = [path for path in lora_paths if not any(exclude in self.normalize_folder_name(path) for exclude in exclude_folders)] path
for path in filtered_lora_paths
if any(self.path_matches_folder(folder, path) for folder in include_folders)
and not any(self.path_matches_folder(exclude, path) for exclude in exclude_folders)
]
lora_candidates = {} max_distance = int(ConfigReader.get_setting("sn0w.LoraSettings.LoraFolderMinDistance", 5) or 5)
loaded_loras = set()
lora_found = False
max_distance = int(ConfigReader.get_setting("sn0w.LoraFolderMinDistance", 5))
self.logger.log("Max Distance: " + str(max_distance), "DEBUG") self.logger.log("Max Distance: " + str(max_distance), "DEBUG")
# Match prompt parts with Lora filenames # Pre-build a filename -> full_path lookup to avoid a nested search loop
for prompt_part in prompt_parts: full_path_lookup = {os.path.split(p)[-1].lower(): p for p in full_lora_paths}
for lora_path in lora_paths:
lora_filename = os.path.split(lora_path)[-1].lower()
# Clean up the filename for matching
processed_lora_filename = lora_filename.replace(".safetensors", "").replace("_", " ")
# Check if prompt part matches the cleaned filename # Pre-process lora_paths once: resolve filenames, display names, and full paths
if prompt_part in processed_lora_filename: lora_entries = [
distance = Utility.levenshtein_distance(prompt_part, processed_lora_filename) (lora_filename, lora_filename.replace(".safetensors", "").replace("_", " "), full_path)
self.logger.log("Processing: Distance: " + str(distance) + " Lora: " + lora_filename + " Tag: " + prompt_part, "DEBUG") for lora_path in lora_paths
# Store matching Loras if within acceptable distance for lora_filename in (os.path.split(lora_path)[-1].lower(),)
for full_path in full_lora_paths: if (full_path := full_path_lookup.get(lora_filename)) is not None
if lora_filename in full_path.lower() and distance <= max_distance: ]
if prompt_part not in lora_candidates:
lora_candidates[prompt_part] = [] # Find the single best matching Lora candidate per prompt part
lora_candidates[prompt_part].append({"full_path": full_path, "distance": distance}) best_candidates = {}
self.logger.log( for prompt_part in prompt_parts:
"Final: Distance: " + str(distance) + " Lora: " + lora_filename + " Tag: " + prompt_part, "DEBUG" for lora_filename, processed_name, full_path in lora_entries:
) distance = Utility.levenshtein_distance(prompt_part, processed_name)
break self.logger.log(f"Processing: Distance: {distance} Lora: {lora_filename} Tag: {prompt_part}", "DEBUG")
if distance > max_distance:
continue
if prompt_part not in best_candidates or distance < best_candidates[prompt_part]["distance"]:
best_candidates[prompt_part] = {"full_path": full_path, "distance": distance}
self.logger.log(f"Final: Distance: {distance} Lora: {lora_filename} Tag: {prompt_part}", "DEBUG")
# Load the best candidate Lora for each prompt part # Load the best candidate Lora for each prompt part
for prompt_part, candidates in lora_candidates.items(): loaded_loras = set()
if candidates: for prompt_part, candidate in best_candidates.items():
lora_found = True folder_name = self.normalize_folder_name(os.path.dirname(candidate["full_path"]))
selected_candidate = min(candidates, key=lambda x: x["distance"]) # Load Lora if not already loaded and within the allowed number per folder
folder_name = self.normalize_folder_name(os.path.dirname(selected_candidate["full_path"])) if candidate["full_path"] not in loaded_loras and (
# Load Lora if not already loaded and within the allowed number per folder folder_name not in include_folders or len(loaded_loras) < include_folders[folder_name]
if selected_candidate["full_path"] not in loaded_loras and ( ):
folder_name not in include_folders or len(loaded_loras) < include_folders[folder_name] self.logger.log(f"Loading Lora: {os.path.split(candidate['full_path'])[-1]}", "INFORMATIONAL")
): model, clip = LoraLoader().load_lora(model, clip, candidate["full_path"], lora_strength, lora_strength)
self.logger.log(f"Loading Lora: {os.path.split(selected_candidate['full_path'])[-1]}", "INFORMATIONAL") loaded_loras.add(candidate["full_path"])
model, clip = LoraLoader().load_lora(model, clip, selected_candidate["full_path"], lora_strength, lora_strength)
loaded_loras.add(selected_candidate["full_path"])
if not lora_found: if not best_candidates:
self.logger.log("No matching Lora found for any tags.", "INFORMATIONAL") self.logger.log("No matching Lora found for any tags.", "INFORMATIONAL")
return (model, clip) return (model, clip)
+61
View File
@@ -0,0 +1,61 @@
import json
import os
from pathlib import Path
from ..sn0w import Logger
PRESETS_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"web",
"settings",
"lora_presets.json",
)
MAX_PRESET_DEPTH = 5
logger = Logger()
def normalize_loader_path(folders):
return ",".join(Path(folder).as_posix().lower() for folder in folders)
def build_lora_loader_info(node_class_mappings):
info = {}
for name, node_class in node_class_mappings.items():
combos = getattr(node_class, "LORA_COMBOS", 1)
folders = getattr(node_class, "LORA_PATH", None)
if combos > 1 and folders:
info[name] = {"path": normalize_loader_path(folders), "combos": combos}
return info
def presets_for_path(path):
return [preset for preset in load_presets() if preset.get("path") == path]
def find_preset(path, name):
return next((preset for preset in presets_for_path(path) if preset.get("name") == name), None)
def load_presets():
if not os.path.exists(PRESETS_FILE):
return []
try:
with open(PRESETS_FILE, "r", encoding="utf-8") as file:
data = json.load(file)
return data if isinstance(data, list) else []
except (json.JSONDecodeError, IOError):
logger.log(f"Failed to read lora presets from {PRESETS_FILE}", "WARNING")
return []
def save_preset(name, path, loras):
presets = [p for p in load_presets() if not (p.get("name") == name and p.get("path") == path)]
presets.append({"name": name, "path": path, "loras": loras})
os.makedirs(os.path.dirname(PRESETS_FILE), exist_ok=True)
try:
with open(PRESETS_FILE, "w", encoding="utf-8") as file:
json.dump(presets, file, indent=2)
return True
except IOError:
logger.log(f"Failed to write lora presets to {PRESETS_FILE}", "ERROR")
return False
+199 -94
View File
@@ -1,9 +1,141 @@
import re from typing import Dict, Set, Tuple
from ..sn0w import ConfigReader, Logger from ..sn0w import ConfigReader, Logger
TokenStrength = tuple[str, float]
TokenStrengthList = list[TokenStrength]
class Tokens:
def __init__(self, temp_value=-9999.0) -> None:
self.temp_value = temp_value
def _should_replace(
self,
current: float,
new: float,
) -> bool:
# Special handling for temp, more important than <1.0.
if current == self.temp_value:
return new > 1.0
if current == 1.0:
return new == self.temp_value or new >= 1.0
return new > current
def deduplicate(
self,
tokens: TokenStrengthList,
) -> TokenStrengthList:
best: dict[str, float] = {}
for token, strength in tokens:
current = best.get(token)
if current is None or self._should_replace(current, strength):
best[token] = strength
return list(best.items())
def _ends_nesting(self, word: str) -> bool:
return word.endswith(")") and not word.endswith(r"\)")
def _parse_strength(self, word: str) -> float | None:
if not self._ends_nesting(word):
return None
parts = word.split(":")
if len(parts) <= 1:
return None
return float(parts[-1].strip(")"))
def tokenize(self, input: str, delimiter=",") -> TokenStrengthList:
if len(delimiter) != 1:
raise ValueError("Delimiter must be a single character")
token_tree: dict[int, list[str]] = {0: []}
strength_tokens: TokenStrengthList = []
current_nesting = 0
word = ""
for index, char in enumerate(input):
is_last = index == len(input) - 1
if is_last:
word += char
if char == delimiter or is_last:
word = word.strip()
token_tree[current_nesting].append(word)
end_nesting = self._ends_nesting(word)
strength = self._parse_strength(word)
for token in token_tree[current_nesting]:
if strength is not None:
strength_tokens.append((token.replace(f":{strength})", "").strip(), strength))
elif end_nesting:
strength_tokens.append((token.strip(")"), self.temp_value))
if end_nesting:
token_tree[current_nesting] = []
current_nesting -= 1
word = ""
continue
if char == "(" and not word.endswith("\\"):
current_nesting += 1
if token_tree.get(current_nesting) is None:
token_tree[current_nesting] = []
continue
word += char
for token in token_tree[0]:
strength_tokens.append((token, 1.0))
return strength_tokens
def merge(self, *token_lists: TokenStrengthList) -> TokenStrengthList:
return self.deduplicate([token for token_list in token_lists for token in token_list])
def sort(self, tokens: TokenStrengthList) -> TokenStrengthList:
def sort_strength(value: float) -> float:
if value == self.temp_value:
return 1.000001
return value
return sorted(tokens, key=lambda t: sort_strength(t[1]), reverse=True)
def to_str(self, tokens: TokenStrengthList, delimiter=", ") -> str:
token_dict: dict[float, list[str]] = {}
for token, strength in tokens:
token_dict.setdefault(strength, []).append(token)
parts: list[str] = []
for key in token_dict:
words = token_dict[key]
chunk = delimiter.join(words)
if key == 1.0:
parts.append(chunk)
continue
if key == self.temp_value:
parts.append(f"({chunk})")
continue
parts.append(f"({chunk}:{key})")
return delimiter.join(parts)
class CombineStringNode: class CombineStringNode:
logger = Logger() logger = Logger()
parser = Tokens()
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -31,7 +163,15 @@ class CombineStringNode:
FUNCTION = "combine_string" FUNCTION = "combine_string"
CATEGORY = "sn0w" CATEGORY = "sn0w"
def simplify_tags(self, tags_string, separator): def parse_string_to_tags(self, input_string: str, separator: str) -> TokenStrengthList:
"""Parse an input string into a list of Tag objects."""
if not input_string or input_string == "None":
return []
return self.parser.tokenize(input_string, delimiter=separator.strip())
def simplify_tags(self, tags: TokenStrengthList) -> Tuple[TokenStrengthList, TokenStrengthList]:
"""Simplify tags by removing redundant or conflicting tags."""
remove_eyes = [ remove_eyes = [
"covering eyes", "covering eyes",
"over eyes", "over eyes",
@@ -45,11 +185,7 @@ class CombineStringNode:
] ]
remove_face = ["facing away"] remove_face = ["facing away"]
keep_face = ["looking at viewer"] keep_face = ["looking at viewer"]
# dictionary defines categories with specific rules for removing or keeping tags.
# remove contains phrases that, if found in a prompt, suggest the tag should be removed.
# keep contains phrases that, if found in a prompt, indicate the tag should be kept.
# if a remove tag is found and no keep tag is found, it will remove all mentions of the category except the tags found in remove.
# keep always takes priority over remove, if any tag in keep is found nothing will be removed
special_phrases = { special_phrases = {
"eye": {"remove": remove_eyes, "keep": keep_face}, "eye": {"remove": remove_eyes, "keep": keep_face},
"sclera": {"remove": remove_eyes, "keep": keep_face}, "sclera": {"remove": remove_eyes, "keep": keep_face},
@@ -57,135 +193,104 @@ class CombineStringNode:
"teeth": {"remove": remove_face, "keep": keep_face}, "teeth": {"remove": remove_face, "keep": keep_face},
} }
# Compile regex patterns outside of loops # Build tag map for substring detection
parenthesized_pattern = re.compile(r"\([^()]*\)") tag_map: Dict[TokenStrength, TokenStrength] = {}
# Extract parenthesized parts
parenthesized_parts = []
def extract_parenthesized(match):
parenthesized_parts.append(match.group())
return f"\0{len(parenthesized_parts)-1}\0"
modified_tags_string = re.sub(parenthesized_pattern, extract_parenthesized, tags_string)
tags = [tag.strip() for tag in modified_tags_string.split(separator)]
final_tags = []
removed_tags = set()
tag_map = {}
for tag in tags: for tag in tags:
for potential_superior in tags: for potential_superior in tags:
if tag in potential_superior and tag != potential_superior: if tag[0] in potential_superior[0] and tag[0] != potential_superior[0]:
tag_map[tag] = potential_superior tag_map[tag] = potential_superior
# Check global keep conditions
all_tag_texts = [tag[0] for tag in tags]
global_keep_conditions = { global_keep_conditions = {
keyword: any(keep_phrase in tags_string for keep_phrase in conditions["keep"]) keyword: any(keep_phrase in all_tag_texts for keep_phrase in conditions["keep"])
for keyword, conditions in special_phrases.items() for keyword, conditions in special_phrases.items()
} }
non_removable_tags = { # Identify non-removable tags (those that match remove phrases)
non_removable_tags: Set[TokenStrength] = {
tag tag
for tag in tags for tag in tags
for keyword, conditions in special_phrases.items() for keyword, conditions in special_phrases.items()
if any(remove_phrase in tag for remove_phrase in conditions["remove"]) if any(remove_phrase in tag[0] for remove_phrase in conditions["remove"])
} }
final_tags: TokenStrengthList = []
removed_tags: TokenStrengthList = []
for tag in tags: for tag in tags:
# Check if this tag has a superior tag
superior_tag = tag_map.get(tag) superior_tag = tag_map.get(tag)
if superior_tag and superior_tag not in final_tags: if superior_tag and superior_tag not in final_tags:
removed_tags.add(tag) removed_tags.append(tag)
continue continue
# Check special phrase rules
should_remove_tag = False should_remove_tag = False
for keyword, conditions in special_phrases.items(): for keyword, conditions in special_phrases.items():
if ( if (
keyword in tag keyword in tag[0]
and not global_keep_conditions[keyword] and not global_keep_conditions[keyword]
and any(remove_phrase in tags_string for remove_phrase in conditions["remove"]) and any(remove_phrase in t[0] for t in tags for remove_phrase in conditions["remove"])
and tag not in non_removable_tags and tag not in non_removable_tags
): ):
should_remove_tag = True should_remove_tag = True
break break
if should_remove_tag: if should_remove_tag:
removed_tags.add(tag) removed_tags.append(tag)
else: else:
# Avoid adding duplicates # Avoid adding duplicates
if tag not in final_tags: if tag not in final_tags:
final_tags.append(tag) final_tags.append(tag)
# Reinsert parenthesized parts and handle removed tags return final_tags, removed_tags
final_tags_with_parentheses = []
for tag in final_tags:
while "\0" in tag:
try:
start_index = tag.find("\0") + 1
end_index = tag.rfind("\0")
index = int(tag[start_index:end_index].strip())
tag = tag.replace(f"\0{index}\0", parenthesized_parts[index], 1)
except ValueError:
self.logger.log(f"Error converting tag index in tag: {tag}", "ERROR")
break
else:
# If no ValueError was caught, add the tag to the final list
final_tags_with_parentheses.append(tag)
simplified_tags_string = separator.join(final_tags_with_parentheses).strip(separator) def apply_implied_tags(self, tags: TokenStrengthList) -> TokenStrengthList:
removed_tags_string = separator.join(removed_tags).strip(separator) """Add implied tags based on configuration."""
implied_tags_config = str(ConfigReader.get_setting("sn0w.ImpliedTags", "")).split("\n")
tag_texts = [tag[0] for tag in tags]
new_tags = list(tags)
return simplified_tags_string, removed_tags_string for pair in implied_tags_config:
parts = pair.split(":")
if len(parts) != 2:
continue
def format_text(self, tags, separator): key, value = parts[0].strip(), parts[1].strip()
animagine_formatting = ConfigReader.get_setting("sn0w.PromptFormat", False) if key in tag_texts and value not in tag_texts:
if animagine_formatting is False: # Insert the implied tag immediately after the key tag
return tags for idx, tag in enumerate(new_tags):
if tag[0] == key:
new_tags.insert(idx + 1, (value, 1.0))
break
numeric_tag_pattern = re.compile(r"\b\d+\+?(girls?|boys?)\b") return new_tags
tags = [tag.strip() for tag in tags.split(separator)]
numeric_tags = [tag for tag in tags if numeric_tag_pattern.match(tag)] def combine_string(self, separator: str, simplify: bool, **kwargs) -> Tuple[str, str]:
non_numeric_tags = [tag for tag in tags if not numeric_tag_pattern.match(tag)] """Main function to combine input strings into a single prompt."""
prioritized_tags = numeric_tags + non_numeric_tags # Collect input strings
input_strings = [kwargs.get(f"string_{char}", "").strip() for char in ["a", "b", "c", "d"]]
input_strings = [s for s in input_strings if s and s != "None"]
return separator.join(prioritized_tags).strip(separator) if not input_strings:
return ("", "")
def combine_string(self, separator, simplify, **kwargs): # Parse all input strings to Tag objects
# Collect strings that are not None and strip them tag_lists: list[TokenStrengthList] = []
strings = [s.strip() for s in (kwargs.get(f"string_{char}") for char in ["a", "b", "c", "d"]) if s] for input_string in input_strings:
removed_tags = "" tag_lists.append(self.parse_string_to_tags(input_string, separator))
all_words = set() all_tags = self.parser.merge(*tag_lists)
combined = [] all_tags = self.apply_implied_tags(all_tags)
for string in strings:
if string != "None" and isinstance(string, str):
string = string.strip()
# Check if the string ends with the given separator and remove it if it does
if string.endswith(separator.strip()):
string = string[: -len(separator.strip())]
# Add all the words to the set and build the final string
if string and string not in combined:
final_string = ""
words = string.split(separator)
for word in words:
if word not in all_words:
all_words.add(word)
final_string += word + separator
# Remove the trailing separator from the final string if it's not empty
if final_string:
final_string = final_string[: -len(separator)]
combined.append(final_string)
# Simplify tags if requested
removed_tags: TokenStrengthList = []
if simplify: if simplify:
final_tags, removed_tags = self.simplify_tags(separator.join(combined), separator) all_tags, removed_tags = self.simplify_tags(all_tags)
else:
final_tags = separator.join(combined)
final_tags = self.format_text(final_tags, separator) # Convert back to string
final_string = self.parser.to_str(all_tags, separator)
removed_string = separator.join([tag[0] for tag in removed_tags])
return (final_tags, removed_tags) return (final_string, removed_string)
+51 -19
View File
@@ -3,8 +3,8 @@ import json
class PromptSelectNode: class PromptSelectNode:
positive_prompts = {} prompts = {}
negative_prompts = {} classifier = None
@classmethod @classmethod
def load_prompts(cls): def load_prompts(cls):
@@ -13,38 +13,70 @@ class PromptSelectNode:
if os.sep + "src" in dir_path: if os.sep + "src" in dir_path:
dir_path = dir_path.replace(os.sep + "src", "") dir_path = dir_path.replace(os.sep + "src", "")
# Define the path to the JSON file containing prompts
json_path = os.path.join(dir_path, "prompts.json") json_path = os.path.join(dir_path, "prompts.json")
# Load prompts from the JSON file
with open(json_path, "r", encoding="utf-8") as file: with open(json_path, "r", encoding="utf-8") as file:
prompts = json.load(file) cls.prompts = json.load(file)
# Separate positive and negative prompts into dictionaries
cls.positive_prompts = {item["name"]: item["prompt"] for item in prompts[0]["positive"]}
cls.negative_prompts = {item["name"]: item["prompt"] for item in prompts[0]["negative"]}
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
cls.load_prompts() cls.load_prompts()
# Generate input types dynamically from the loaded prompts model_names = list(cls.prompts.keys())
positive_prompt_names = ["None"] + list(cls.positive_prompts.keys())
negative_prompt_names = ["None"] + list(cls.negative_prompts.keys())
return { return {
"required": { "required": {
"positive": (positive_prompt_names,), "prompt": ("STRING", {"default": ""}),
"negative": (negative_prompt_names,), "model": (model_names,),
"separator": ("STRING", {"default": ", "}),
}, },
} }
NSFW_WORDS = [
"uncensored",
"penis",
"sex",
"completely nude",
"genitals",
"vagina",
"pussy",
"dick",
"cock",
"asshole",
"masturbat",
"orgasm",
"ejaculat",
"threesome",
"gangbang",
"orgy",
"incest",
"squirt",
"creampie",
"futanari",
"erection",
"dildo",
"vibrator",
"pegging",
]
def is_nsfw(self, prompt):
return any(word.lower() in prompt.lower() for word in self.NSFW_WORDS if word)
# Define static properties for the class # Define static properties for the class
RETURN_TYPES = ("STRING", "STRING") RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("POSITIVE", "NEGATIVE") RETURN_NAMES = ("POSITIVE", "NEGATIVE")
FUNCTION = "find_chosen_prompts" FUNCTION = "find_chosen_prompts"
CATEGORY = "sn0w" CATEGORY = "sn0w"
def find_chosen_prompts(self, positive, negative): def find_chosen_prompts(self, prompt, model, separator: str):
# Lookup the prompt name in both positive and negative dictionaries and return the associated strings positive_prompt = self.prompts.get(model, {}).get("positive", "")
positive_prompt = self.positive_prompts.get(positive, "") negative_prompt = self.prompts.get(model, {}).get("negative", "")
negative_prompt = self.negative_prompts.get(negative, "")
is_nsfw = self.is_nsfw(prompt)
if is_nsfw:
if "uncensored" not in prompt:
positive_prompt = positive_prompt + separator + "uncensored"
negative_prompt = (
negative_prompt
+ separator
+ separator.join(["censored", "bar censor", "blur censor", "mosaic censoring"])
)
return (positive_prompt, negative_prompt) return (positive_prompt, negative_prompt)
+221 -12
View File
@@ -1,12 +1,198 @@
from nodes import VAEDecode, EmptyLatentImage, CLIPTextEncode import torch
from comfy_extras.nodes_custom_sampler import SamplerCustom, BasicScheduler, PolyexponentialScheduler, VPScheduler, SplitSigmasDenoise import latent_preview
import comfy.samplers import comfy.samplers
from nodes import VAEDecode, EmptyLatentImage, CLIPTextEncode
from comfy.k_diffusion import sampling as k_diffusion_sampling
from comfy_extras.nodes_align_your_steps import AlignYourStepsScheduler from comfy_extras.nodes_align_your_steps import AlignYourStepsScheduler
from ..sn0w import Logger, Utility from ..sn0w import Logger, Utility
from .custom_schedulers.custom_schedulers import CustomSchedulers from .custom_schedulers.custom_schedulers import CustomSchedulers
class Noise_EmptyNoise:
def __init__(self):
self.seed = 0
def generate_noise(self, input_latent):
latent_image = input_latent["samples"]
return torch.zeros(latent_image.shape, dtype=latent_image.dtype, layout=latent_image.layout, device="cpu")
class Noise_RandomNoise:
def __init__(self, seed):
self.seed = seed
def generate_noise(self, input_latent):
latent_image = input_latent["samples"]
batch_inds = input_latent["batch_index"] if "batch_index" in input_latent else None
return comfy.sample.prepare_noise(latent_image, self.seed, batch_inds)
class _SplitSigmasDenoise:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sigmas": ("SIGMAS",),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("SIGMAS", "SIGMAS")
RETURN_NAMES = ("high_sigmas", "low_sigmas")
CATEGORY = "sampling/custom_sampling/sigmas"
FUNCTION = "get_sigmas"
def get_sigmas(self, sigmas, denoise):
steps = max(sigmas.shape[-1] - 1, 0)
total_steps = round(steps * denoise)
sigmas1 = sigmas[:-(total_steps)]
sigmas2 = sigmas[-(total_steps + 1) :]
return (sigmas1, sigmas2)
class _VPScheduler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"beta_d": ("FLOAT", {"default": 19.9, "min": 0.0, "max": 5000.0, "step": 0.01, "round": False}), # TODO: fix default values
"beta_min": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 5000.0, "step": 0.01, "round": False}),
"eps_s": ("FLOAT", {"default": 0.001, "min": 0.0, "max": 1.0, "step": 0.0001, "round": False}),
}
}
RETURN_TYPES = ("SIGMAS",)
CATEGORY = "sampling/custom_sampling/schedulers"
FUNCTION = "get_sigmas"
def get_sigmas(self, steps, beta_d, beta_min, eps_s):
sigmas = k_diffusion_sampling.get_sigmas_vp(n=steps, beta_d=beta_d, beta_min=beta_min, eps_s=eps_s)
return (sigmas,)
class _PolyexponentialScheduler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"sigma_max": ("FLOAT", {"default": 14.614642, "min": 0.0, "max": 5000.0, "step": 0.01, "round": False}),
"sigma_min": ("FLOAT", {"default": 0.0291675, "min": 0.0, "max": 5000.0, "step": 0.01, "round": False}),
"rho": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.01, "round": False}),
}
}
RETURN_TYPES = ("SIGMAS",)
CATEGORY = "sampling/custom_sampling/schedulers"
FUNCTION = "get_sigmas"
def get_sigmas(self, steps, sigma_max, sigma_min, rho):
sigmas = k_diffusion_sampling.get_sigmas_polyexponential(n=steps, sigma_min=sigma_min, sigma_max=sigma_max, rho=rho)
return (sigmas,)
class _BasicScheduler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"scheduler": (comfy.samplers.SCHEDULER_NAMES,),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("SIGMAS",)
CATEGORY = "sampling/custom_sampling/schedulers"
FUNCTION = "get_sigmas"
def get_sigmas(self, model, scheduler, steps, denoise):
total_steps = steps
if denoise < 1.0:
if denoise <= 0.0:
return (torch.FloatTensor([]),)
total_steps = int(steps / denoise)
sigmas = comfy.samplers.calculate_sigmas(model.get_model_object("model_sampling"), scheduler, total_steps).cpu()
sigmas = sigmas[-(steps + 1) :]
return (sigmas,)
class _SamplerCustom:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"add_noise": ("BOOLEAN", {"default": True}),
"noise_seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01}),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"sampler": ("SAMPLER",),
"sigmas": ("SIGMAS",),
"latent_image": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "LATENT")
RETURN_NAMES = ("output", "denoised_output")
FUNCTION = "sample"
CATEGORY = "sampling/custom_sampling"
def sample(self, model, add_noise, noise_seed, cfg, positive, negative, sampler, sigmas, latent_image):
latent = latent_image
latent_image = latent["samples"]
latent = latent.copy()
latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image)
latent["samples"] = latent_image
if not add_noise:
noise = Noise_EmptyNoise().generate_noise(latent)
else:
noise = Noise_RandomNoise(noise_seed).generate_noise(latent)
noise_mask = None
if "noise_mask" in latent:
noise_mask = latent["noise_mask"]
x0_output = {}
callback = latent_preview.prepare_callback(model, sigmas.shape[-1] - 1, x0_output)
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
samples = comfy.sample.sample_custom(
model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_image,
noise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=noise_seed,
)
out = latent.copy()
out["samples"] = samples
if "x0" in x0_output:
out_denoised = latent.copy()
out_denoised["samples"] = model.model.process_latent_out(x0_output["x0"].cpu())
else:
out_denoised = out
return (out, out_denoised)
class SimpleSamplerCustom: class SimpleSamplerCustom:
logger = Logger() logger = Logger()
@@ -58,6 +244,10 @@ class SimpleSamplerCustom:
}, },
} }
@classmethod
def VALIDATE_INPUTS(cls, input_types):
return True
RETURN_TYPES = ("IMAGE", "LATENT", "CONDITIONING", "CONDITIONING") RETURN_TYPES = ("IMAGE", "LATENT", "CONDITIONING", "CONDITIONING")
RETURN_NAMES = ("IMAGE", "LATENT", "POSITIVE", "NEGATIVE") RETURN_NAMES = ("IMAGE", "LATENT", "POSITIVE", "NEGATIVE")
@@ -65,8 +255,23 @@ class SimpleSamplerCustom:
CATEGORY = "sampling/custom_sampling" CATEGORY = "sampling/custom_sampling"
def sample(self, model, clip, vae, add_noise, noise_seed, steps, cfg, sampler_name, scheduler_name, denoise, width, height, **kwargs): def sample(
custom_sampler = SamplerCustom() self,
model,
clip,
vae,
add_noise,
noise_seed,
steps,
cfg,
sampler_name,
scheduler_name,
denoise,
width,
height,
**kwargs,
):
custom_sampler = _SamplerCustom()
vae_decode = VAEDecode() vae_decode = VAEDecode()
text_encode = CLIPTextEncode() text_encode = CLIPTextEncode()
@@ -82,10 +287,14 @@ class SimpleSamplerCustom:
self.logger.print_sigmas_differences(scheduler_name, sigmas) self.logger.print_sigmas_differences(scheduler_name, sigmas)
samples = custom_sampler.sample(model, add_noise, noise_seed, cfg, positive_prompt, negative_prompt, sampler, sigmas, latent_image) samples = custom_sampler.sample(model, add_noise, noise_seed, cfg, positive_prompt, negative_prompt, sampler, sigmas, latent_image)
if image_output["links"] != []: try:
if image_output is not None and image_output["links"] != []:
image = vae_decode.decode(vae, samples[1])
else:
image = (None,)
except (TypeError, KeyError):
# If we get a TypeError (NoneType) or KeyError, decode the image
image = vae_decode.decode(vae, samples[1]) image = vae_decode.decode(vae, samples[1])
else:
image = (None,)
return (image[0], samples[1], positive_prompt, negative_prompt) return (image[0], samples[1], positive_prompt, negative_prompt)
@@ -93,7 +302,7 @@ class SimpleSamplerCustom:
if denoise == 1: if denoise == 1:
return (None, sigmas) return (None, sigmas)
return SplitSigmasDenoise.get_sigmas(self, sigmas, denoise) return _SplitSigmasDenoise.get_sigmas(self, sigmas, denoise)
def get_custom_sigmas(self, model, model_type, scheduler_name, steps, denoise): def get_custom_sigmas(self, model, model_type, scheduler_name, steps, denoise):
if denoise > 0 and scheduler_name not in comfy.samplers.KSampler.SCHEDULERS: if denoise > 0 and scheduler_name not in comfy.samplers.KSampler.SCHEDULERS:
@@ -104,11 +313,11 @@ class SimpleSamplerCustom:
model_type = "SDXL" model_type = "SDXL"
sigmas = AlignYourStepsScheduler.get_sigmas(self, model_type, steps, denoise)[0] sigmas = AlignYourStepsScheduler.get_sigmas(self, model_type, steps, denoise)[0]
elif scheduler_name == "polyexponential": elif scheduler_name == "polyexponential":
sigmas = self.get_denoised_sigmas(PolyexponentialScheduler.get_sigmas(self, steps, 14.614642, 0.0291675, 1.0)[0], denoise)[1] sigmas = self.get_denoised_sigmas(_PolyexponentialScheduler.get_sigmas(self, steps, 14.614642, 0.0291675, 1.0)[0], denoise)[1]
elif scheduler_name == "vp": elif scheduler_name == "vp":
sigmas = self.get_denoised_sigmas(VPScheduler.get_sigmas(self, steps, 14.0, 0.05, 0.075)[0], denoise)[1] sigmas = self.get_denoised_sigmas(_VPScheduler.get_sigmas(self, steps, 14.0, 0.05, 0.075)[0], denoise)[1]
elif scheduler_name in comfy.samplers.KSampler.SCHEDULERS: elif scheduler_name in comfy.samplers.KSampler.SCHEDULERS:
sigmas = BasicScheduler.get_sigmas(self, model, scheduler_name, steps, denoise)[0] sigmas = _BasicScheduler.get_sigmas(self, model, scheduler_name, steps, denoise)[0]
else: else:
defaults = self.get_custom_scheduler_defaults(scheduler_name) defaults = self.get_custom_scheduler_defaults(scheduler_name)
sigmas = self.get_denoised_sigmas(self.get_custom_class_sigmas(scheduler_name, steps, *defaults.values())[0], denoise)[1] sigmas = self.get_denoised_sigmas(self.get_custom_class_sigmas(scheduler_name, steps, *defaults.values())[0], denoise)[1]
+90 -4
View File
@@ -1,5 +1,91 @@
from comfy_extras.nodes_upscale_model import ImageUpscaleWithModel import torch
from nodes import ImageScaleBy import comfy.utils
from comfy import model_management
class _ImageUpscaleWithModel:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"upscale_model": ("UPSCALE_MODEL",),
"image": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "upscale"
CATEGORY = "image/upscaling"
def upscale(self, upscale_model, image):
device = model_management.get_torch_device()
memory_required = model_management.module_size(upscale_model.model)
memory_required += (
(512 * 512 * 3) * image.element_size() * max(upscale_model.scale, 1.0) * 384.0
) # The 384.0 is an estimate of how much some of these models take, TODO: make it more accurate
memory_required += image.nelement() * image.element_size()
model_management.free_memory(memory_required, device)
upscale_model.to(device)
in_img = image.movedim(-1, -3).to(device)
tile = 512
overlap = 32
oom = True
while oom:
try:
steps = in_img.shape[0] * comfy.utils.get_tiled_scale_steps(
in_img.shape[3], in_img.shape[2], tile_x=tile, tile_y=tile, overlap=overlap
)
pbar = comfy.utils.ProgressBar(steps)
s = comfy.utils.tiled_scale(
in_img,
lambda a: upscale_model(a),
tile_x=tile,
tile_y=tile,
overlap=overlap,
upscale_amount=upscale_model.scale,
pbar=pbar,
)
oom = False
except model_management.OOM_EXCEPTION as e:
tile //= 2
if tile < 128:
raise e
upscale_model.to("cpu")
s = torch.clamp(s.movedim(-3, -1), min=0, max=1.0)
return (s,)
class _ImageScaleBy:
upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"upscale_method": (s.upscale_methods,),
"scale_by": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 8.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "upscale"
CATEGORY = "image/upscaling"
def upscale(self, image, upscale_method, scale_by):
samples = image.movedim(-1, 1)
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
s = comfy.utils.common_upscale(samples, width, height, upscale_method, "disabled")
s = s.movedim(1, -1)
return (s,)
class UpscaleImageBy: class UpscaleImageBy:
@@ -34,8 +120,8 @@ class UpscaleImageBy:
return factor_width return factor_width
def upscale(self, image, upscale_by, upscale_model): def upscale(self, image, upscale_by, upscale_model):
upscaler = ImageUpscaleWithModel() upscaler = _ImageUpscaleWithModel()
scale_image = ImageScaleBy() scale_image = _ImageScaleBy()
# Get original image size # Get original image size
original_size = self.get_image_size(image) original_size = self.get_image_size(image)
+486
View File
@@ -0,0 +1,486 @@
from .upscale_with_model_by import UpscaleImageBy
import torch
import math
import comfy.utils
import numpy as np
from PIL import Image
import comfy.samplers
from nodes import KSampler, VAEEncode, VAEDecode, CLIPTextEncode
from .wd14.tagger import tag as wd14_tag, wait_for_async
from ..sn0w import Logger
class AutoTaggedTiledUpscaler:
logger = Logger()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"upscale_by": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01}),
"upscale_model": ("UPSCALE_MODEL",),
"split_parts": ("INT", {"default": 4, "min": 1, "max": 128, "step": 1}),
"overlap_pixels": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8}),
# Standard KSampler parameters - required when split_parts > 1
"model": ("MODEL",),
"vae": ("VAE",),
"clip": ("CLIP",),
"positive": ("STRING", {"default": ""}),
"negative": ("CONDITIONING",),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"steps": ("INT", {"default": 10, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
"denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "round": 0.01}),
# WD14 tagger parameters for auto-tagging
"tagger_model": (
[
"wd-v1-4-moat-tagger-v2",
"wd-v1-4-convnextv2-tagger-v2",
"wd-v1-4-convnext-tagger-v2",
"wd-v1-4-vit-tagger-v2",
],
{"default": "wd-v1-4-moat-tagger-v2"},
),
"tag_threshold": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.05}),
"character_threshold": ("FLOAT", {"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.05}),
"exclude_tags": ("STRING", {"default": ""}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "upscale"
CATEGORY = "image/upscaling"
# Minimum dimensions for split parts in latent space
# Each latent pixel corresponds to 8x8 pixels in pixel space
MIN_LATENT_SIZE = 16
def upscale(
self,
image,
upscale_by,
upscale_model,
split_parts=1,
overlap_pixels=128,
model=None,
vae=None,
clip=None,
positive="",
negative=None,
seed=0,
steps=10,
cfg=8.0,
sampler_name=None,
scheduler=None,
denoise=0.4,
tagger_model="wd-v1-4-moat-tagger-v2",
tag_threshold=0.35,
character_threshold=0.85,
exclude_tags="",
prompt=None,
extra_pnginfo=None,
):
upscaler = UpscaleImageBy()
upscaled_image = upscaler.upscale(image, upscale_by, upscale_model)[0]
batch_size, height, width, channels = upscaled_image.shape
# If no splitting is requested, return upscaled image as is
if split_parts <= 1:
return (upscaled_image,)
# Check the image dimensions to determine max number of splits
batch_size, height, width, channels = upscaled_image.shape # Images in ComfyUI are [B,H,W,C]
# The latent space is usually 8x smaller in each dimension
# Calculate max parts based on minimum latent size requirements
latent_height = height // 8 # VAE downsampling factor
latent_width = width // 8
max_rows = max(1, latent_height // self.MIN_LATENT_SIZE)
max_cols = max(1, latent_width // self.MIN_LATENT_SIZE)
max_parts = max_rows * max_cols
# Adjust split_parts if needed to avoid parts that are too small
if split_parts > max_parts:
# Log warning and adjust split_parts
self.logger.log(
f"Reduced split_parts from {split_parts} to {max_parts} to maintain minimum latent size", "WARNING"
)
split_parts = max_parts
# Ensure split_parts is at least 1
split_parts = max(1, split_parts)
grid_size = math.ceil(math.sqrt(split_parts))
rows = grid_size
cols = math.ceil(split_parts / rows)
self.logger.log(f"Grid: {rows}x{cols}, {split_parts} parts, overlap: {overlap_pixels}px", "DEBUG")
# Split the upscaled image with overlap
split_info = self.split_image(upscaled_image, split_parts, overlap_pixels)
# Process each split in stages to reduce model swapping:
# 1. tag all images, 2. encode all prompts/latents, 3. sample all latents, 4. decode all images
processed_splits = []
k_sampler = KSampler()
vae_encode = VAEEncode()
vae_decode = VAEDecode()
text_encode = CLIPTextEncode()
tagged_prompts = []
prompt_conditionings = []
latents = []
sampled_latents = []
for i, part_data in enumerate(split_info):
split_image = part_data["image"] # Extract the actual image tensor
b, h, w, c = split_image.shape
self.logger.log(f"Part {i + 1}/{len(split_info)}: {w}x{h}", "DEBUG")
# Stage 1: tag every split first
for i, part_data in enumerate(split_info):
split_image = part_data["image"]
img_for_tagging = self.tensor_to_pil(split_image)
auto_tags = wait_for_async(
lambda: wd14_tag(
img_for_tagging,
tagger_model,
threshold=tag_threshold,
character_threshold=character_threshold,
exclude_tags=exclude_tags,
replace_underscore=True,
)
)
combined_prompt = auto_tags
if positive and isinstance(positive, str) and positive.strip():
combined_prompt = f"{auto_tags}, {positive.strip()}"
tagged_prompts.append(combined_prompt)
self.logger.log(f"Tagged part {i + 1}/{len(split_info)}", "DEBUG")
# Stage 2: encode all prompts and images
for i, part_data in enumerate(split_info):
split_image = part_data["image"]
positive_conditioning = text_encode.encode(clip, tagged_prompts[i])[0]
latent = vae_encode.encode(vae, split_image)[0]
prompt_conditionings.append(positive_conditioning)
latents.append(latent)
self.logger.log(f"Encoded part {i + 1}/{len(split_info)}", "DEBUG")
# Stage 3: sample all latents
for i, latent in enumerate(latents):
processed_latent = k_sampler.sample(
model=model,
seed=seed,
steps=steps,
cfg=cfg,
sampler_name=sampler_name,
scheduler=scheduler,
positive=prompt_conditionings[i],
negative=negative,
latent_image=latent,
denoise=denoise,
)[0]
sampled_latents.append(processed_latent)
self.logger.log(f"Sampled part {i + 1}/{len(latents)}", "DEBUG")
# Stage 4: decode all sampled latents
for i, sampled_latent in enumerate(sampled_latents):
processed_image = vae_decode.decode(vae, sampled_latent)[0]
processed_splits.append(processed_image)
self.logger.log(f"Decoded part {i + 1}/{len(sampled_latents)}", "DEBUG")
# Stitch the processed images back together with fade effect
result = self.stitch_images(processed_splits, batch_size, channels, height, width, split_parts, overlap_pixels)
return (result,)
def tensor_to_pil(self, tensor):
"""Convert a ComfyUI tensor to a PIL Image for tagging"""
# ComfyUI tensors are in the range [0, 1], so scale to [0, 255]
img_array = (tensor[0].cpu().numpy() * 255).astype(np.uint8)
return Image.fromarray(img_array)
def split_image(self, image, num_parts, overlap_pixels):
batch_size, height, width, channels = image.shape # Use proper BHWC format
result_images = []
# Calculate grid dimensions (trying to make it as square as possible)
grid_size = math.ceil(math.sqrt(num_parts))
rows = grid_size
cols = math.ceil(num_parts / rows)
# Calculate part dimensions
part_height = height // rows
part_width = width // cols
self.logger.log(f"Original Upscaled: {width}x{height}, part size: {part_width}x{part_height}", "INFORMATIONAL")
# Extract parts with proper handling of edges and corners
parts_created = 0
for r in range(rows):
if parts_created >= num_parts:
break
for c in range(cols):
if parts_created >= num_parts:
break
# Base coordinates for this part
base_y_start = r * part_height
base_y_end = min((r + 1) * part_height, height)
base_x_start = c * part_width
base_x_end = min((c + 1) * part_width, width)
# Add overlap only where appropriate (don't go beyond borders)
# Top edge - only add overlap if not first row
y_start = base_y_start
if r > 0:
y_start = max(0, base_y_start - overlap_pixels)
# Bottom edge - only add overlap if not last row
y_end = base_y_end
if r < rows - 1:
y_end = min(height, base_y_end + overlap_pixels)
# Left edge - only add overlap if not first column
x_start = base_x_start
if c > 0:
x_start = max(0, base_x_start - overlap_pixels)
# Right edge - only add overlap if not last column
x_end = base_x_end
if c < cols - 1:
x_end = min(width, base_x_end + overlap_pixels)
# Extract part - making sure to keep all channels
# ComfyUI uses BHWC format [batch, height, width, channels]
part = image[:, y_start:y_end, x_start:x_end, :]
actual_width = x_end - x_start
actual_height = y_end - y_start
self.logger.log(f"Part {parts_created + 1}: [{c},{r}], {actual_width}x{actual_height}", "DEBUG")
# Store the part along with its position info for stitching
result_images.append(
{
"image": part,
"base_coords": (base_y_start, base_y_end, base_x_start, base_x_end),
"full_coords": (y_start, y_end, x_start, x_end),
}
)
parts_created += 1
if parts_created >= num_parts:
break
return result_images
def stitch_images(self, processed_images, batch_size, channels, orig_height, orig_width, num_parts, overlap_pixels):
# Create empty result tensor and weight mask for blending
result = torch.zeros(
(batch_size, orig_height, orig_width, channels),
dtype=processed_images[0].dtype,
device=processed_images[0].device,
)
# Weight mask to track contribution of each pixel (for proper blending)
weights = torch.zeros(
(batch_size, orig_height, orig_width, 1), dtype=torch.float32, device=processed_images[0].device
)
# Calculate grid dimensions
grid_size = math.ceil(math.sqrt(num_parts))
rows = grid_size
cols = math.ceil(num_parts / rows)
# Calculate base part dimensions (non-overlapping)
part_height = orig_height // rows
part_width = orig_width // cols
# Need to reconstruct the split part coordinates
parts_info = []
part_idx = 0
for r in range(rows):
if part_idx >= num_parts:
break
for c in range(cols):
if part_idx >= num_parts:
break
# Base coordinates (non-overlapping part)
base_y_start = r * part_height
base_y_end = min((r + 1) * part_height, orig_height)
base_x_start = c * part_width
base_x_end = min((c + 1) * part_width, orig_width)
# Full coordinates (with overlap)
# Top edge - only add overlap if not first row
y_start = base_y_start
if r > 0:
y_start = max(0, base_y_start - overlap_pixels)
# Bottom edge - only add overlap if not last row
y_end = base_y_end
if r < rows - 1:
y_end = min(orig_height, base_y_end + overlap_pixels)
# Left edge - only add overlap if not first column
x_start = base_x_start
if c > 0:
x_start = max(0, base_x_start - overlap_pixels)
# Right edge - only add overlap if not last column
x_end = base_x_end
if c < cols - 1:
x_end = min(orig_width, base_x_end + overlap_pixels)
# Store position information for this part
parts_info.append(
{
"index": part_idx,
"base": (base_y_start, base_y_end, base_x_start, base_x_end),
"full": (y_start, y_end, x_start, x_end),
}
)
part_idx += 1
# Helper function for smoother blending using cosine^2 fade
def smooth_fade(x):
"""
Creates an even smoother transition using cosine^2
x should be between 0 and 1
Returns a value between 0 and 1
"""
cosine_val = 0.5 * (1 - math.cos(math.pi * x))
# Square it for an even smoother transition
return cosine_val * cosine_val
# Now place each part with proper weight masks for blending
for part_info in parts_info:
idx = part_info["index"]
if idx >= len(processed_images):
continue
processed_img = processed_images[idx]
# Get the coordinates
y_start, y_end, x_start, x_end = part_info["full"]
base_y_start, base_y_end, base_x_start, base_x_end = part_info["base"]
# Create weight mask for fading
h_size = y_end - y_start
w_size = x_end - x_start
# Handle possible size mismatches between processed image and the expected section area
effective_h = min(h_size, processed_img.shape[1])
effective_w = min(w_size, processed_img.shape[2])
# Create weight mask starting with all 1s (full weight)
weight_mask = torch.ones((effective_h, effective_w, 1), device=processed_img.device)
# Create coordinate grids for more precise blending
y_coords = (
torch.arange(effective_h, device=processed_img.device).reshape(-1, 1, 1).repeat(1, effective_w, 1)
)
x_coords = (
torch.arange(effective_w, device=processed_img.device).reshape(1, -1, 1).repeat(effective_h, 1, 1)
)
# Apply blending on all sides where there's overlap
# Left edge fade
if x_start < base_x_start:
fade_width = base_x_start - x_start
left_mask = x_coords.float() / fade_width
left_mask = torch.clamp(left_mask, 0.0, 1.0)
# Apply smooth fade function to each position
for x in range(min(fade_width, effective_w)):
mask_val = smooth_fade(left_mask[0, x, 0].item())
weight_mask[:, x : x + 1, :] *= mask_val
# Right edge fade
if x_end > base_x_end:
fade_width = x_end - base_x_end
right_offset = base_x_end - x_start
if right_offset < 0:
right_offset = 0
for x in range(min(fade_width, effective_w)):
if right_offset + x < effective_w:
pos = right_offset + x
# Create reverse fade (1 to 0)
mask_val = smooth_fade(1.0 - (x / fade_width))
weight_mask[:, pos : pos + 1, :] *= mask_val
# Top edge fade
if y_start < base_y_start:
fade_height = base_y_start - y_start
top_mask = y_coords.float() / fade_height
top_mask = torch.clamp(top_mask, 0.0, 1.0)
# Apply smooth fade function to each position
for y in range(min(fade_height, effective_h)):
mask_val = smooth_fade(top_mask[y, 0, 0].item())
weight_mask[y : y + 1, :, :] *= mask_val
# Bottom edge fade
if y_end > base_y_end:
fade_height = y_end - base_y_end
bottom_offset = base_y_end - y_start
if bottom_offset < 0:
bottom_offset = 0
for y in range(min(fade_height, effective_h)):
if bottom_offset + y < effective_h:
pos = bottom_offset + y
# Create reverse fade (1 to 0)
mask_val = smooth_fade(1.0 - (y / fade_height))
weight_mask[pos : pos + 1, :, :] *= mask_val
# Apply special handling for corners where two fades would multiply
# This prevents too much darkening in corners
if x_start < base_x_start and y_start < base_y_start:
# Top-left corner: use max of individual fades rather than multiplication
corner_width = base_x_start - x_start
corner_height = base_y_start - y_start
for y in range(min(corner_height, effective_h)):
for x in range(min(corner_width, effective_w)):
# Get fade values for both directions
y_fade = smooth_fade(y / corner_height)
x_fade = smooth_fade(x / corner_width)
# Use the stronger fade value (max) rather than multiplying them
corner_fade = max(y_fade, x_fade)
weight_mask[y, x, :] = corner_fade
# Extract the actual image slice to use
img_slice = processed_img[0, :effective_h, :effective_w, :]
# Expand the weight mask to match the number of channels
expanded_weight = weight_mask.expand(-1, -1, channels)
# Apply weighted contribution to result
result[0, y_start : y_start + effective_h, x_start : x_start + effective_w, :] += (
img_slice * expanded_weight
)
weights[0, y_start : y_start + effective_h, x_start : x_start + effective_w, :] += weight_mask
# Normalize by weights to get the final result (avoid division by zero)
epsilon = 1e-10
weights = weights.expand(-1, -1, -1, channels) # Expand weights to match channels
normalized_result = result / (weights + epsilon)
return normalized_result
+306
View File
@@ -0,0 +1,306 @@
# https://huggingface.co/spaces/SmilingWolf/wd-v1-4-tags
# https://github.com/pythongosssss/ComfyUI-WD14-Tagger
import comfy.utils
import asyncio
import aiohttp
import numpy as np
import csv
import os
import onnxruntime as ort
from onnxruntime import InferenceSession
from PIL import Image
from server import PromptServer
from aiohttp import web
import folder_paths
from tqdm import tqdm
# Default config settings
defaults = {
"model": "wd-v1-4-moat-tagger-v2",
"threshold": 0.35,
"character_threshold": 0.85,
"replace_underscore": False,
"trailing_comma": False,
"exclude_tags": "",
"ortProviders": ["CPUExecutionProvider"], # Changed to CPU-only by default
"HF_ENDPOINT": "https://huggingface.co",
}
# Model definitions
models = {
"wd-eva02-large-tagger-v3": "{HF_ENDPOINT}/SmilingWolf/wd-eva02-large-tagger-v3",
"wd-vit-tagger-v3": "{HF_ENDPOINT}/SmilingWolf/wd-vit-tagger-v3",
"wd-swinv2-tagger-v3": "{HF_ENDPOINT}/SmilingWolf/wd-swinv2-tagger-v3",
"wd-convnext-tagger-v3": "{HF_ENDPOINT}/SmilingWolf/wd-convnext-tagger-v3",
"wd-v1-4-moat-tagger-v2": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-moat-tagger-v2",
"wd-v1-4-convnextv2-tagger-v2": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-convnextv2-tagger-v2",
"wd-v1-4-convnext-tagger-v2": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-convnext-tagger-v2",
"wd-v1-4-convnext-tagger": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-convnext-tagger",
"wd-v1-4-vit-tagger-v2": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-vit-tagger-v2",
"wd-v1-4-swinv2-tagger-v2": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-swinv2-tagger-v2",
"wd-v1-4-vit-tagger": "{HF_ENDPOINT}/SmilingWolf/wd-v1-4-vit-tagger",
"Z3D-E621-Convnext": "{HF_ENDPOINT}/silveroxides/Z3D-E621-Convnext",
}
# Determine models directory
if "wd14_tagger" in folder_paths.folder_names_and_paths:
models_dir = folder_paths.get_folder_paths("wd14_tagger")[0]
if not os.path.exists(models_dir):
os.makedirs(models_dir)
else:
# Use extension directory if folder paths not defined
ext_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
models_dir = os.path.join(ext_dir, "models")
if not os.path.exists(models_dir):
os.makedirs(models_dir)
print(f"WD14Tagger: Available ORT providers: {', '.join(ort.get_available_providers())}")
print(f"WD14Tagger: Using ORT providers: {', '.join(defaults['ortProviders'])}")
def get_installed_models():
"""Get a list of installed ONNX models with matching CSV files"""
if not os.path.exists(models_dir):
return []
models_list = filter(lambda x: x.endswith(".onnx"), os.listdir(models_dir))
models_list = [m for m in models_list if os.path.exists(os.path.join(models_dir, os.path.splitext(m)[0] + ".csv"))]
return models_list
known_models = list(models.keys())
async def download_to_file(url, destination, update_callback=None, session=None):
"""Download a file from URL to the destination with progress tracking"""
close_session = False
if session is None:
close_session = True
loop = None
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
session = aiohttp.ClientSession(loop=loop)
try:
proxy = os.getenv("HTTP_PROXY") or os.getenv("http_proxy")
proxy_auth = None
if proxy:
proxy_auth = aiohttp.BasicAuth(os.getenv("PROXY_USER", ""), os.getenv("PROXY_PASS", ""))
async with session.get(url, proxy=proxy, proxy_auth=proxy_auth) as response:
size = int(response.headers.get("content-length", 0)) or None
with tqdm(
unit="B",
unit_scale=True,
miniters=1,
desc=url.split("/")[-1],
total=size,
) as progressbar:
with open(destination, mode="wb") as f:
perc = 0
async for chunk in response.content.iter_chunked(2048):
f.write(chunk)
progressbar.update(len(chunk))
if update_callback is not None and progressbar.total is not None and progressbar.total != 0:
last = perc
perc = round(progressbar.n / progressbar.total, 2)
if perc != last:
last = perc
await update_callback(perc)
finally:
if close_session and session is not None:
await session.close()
def wait_for_async(async_fn):
try:
import concurrent.futures
# Check if we're in a running event loop
asyncio.get_running_loop()
# We're in a running loop, so run the async function in a separate thread
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, async_fn())
return future.result() # This blocks until complete
except RuntimeError:
# No running loop, safe to use asyncio.run()
return asyncio.run(async_fn())
def update_node_status(client_id, node, text, progress=None):
"""Update node status in the UI with progress information"""
if client_id is None:
client_id = getattr(PromptServer.instance, "client_id", None)
if client_id is None:
return
PromptServer.instance.send_sync("status_update", {"node": node, "progress": progress, "text": text}, client_id)
async def tag(
image,
model_name,
threshold=0.35,
character_threshold=0.85,
exclude_tags="",
replace_underscore=True,
trailing_comma=False,
client_id=None,
node=None,
):
"""Tag an image with the WD14 tagger model"""
if model_name.endswith(".onnx"):
model_name = model_name[0:-5]
installed = list(get_installed_models())
if not any(model_name + ".onnx" in s for s in installed):
await download_model(model_name, client_id, node)
name = os.path.join(models_dir, model_name + ".onnx")
model = InferenceSession(name, providers=defaults["ortProviders"])
input = model.get_inputs()[0]
height = input.shape[1]
# Reduce to max size and pad with white
ratio = float(height) / max(image.size)
new_size = tuple([int(x * ratio) for x in image.size])
image = image.resize(new_size, Image.LANCZOS)
square = Image.new("RGB", (height, height), (255, 255, 255))
square.paste(image, ((height - new_size[0]) // 2, (height - new_size[1]) // 2))
image = np.array(square).astype(np.float32)
image = image[:, :, ::-1] # RGB -> BGR
image = np.expand_dims(image, 0)
# Read all tags from csv and locate start of each category
tags = []
general_index = None
character_index = None
with open(os.path.join(models_dir, model_name + ".csv")) as f:
reader = csv.reader(f)
next(reader)
for row in reader:
if general_index is None and row[2] == "0":
general_index = reader.line_num - 2
elif character_index is None and row[2] == "4":
character_index = reader.line_num - 2
if replace_underscore:
tags.append(row[1].replace("_", " "))
else:
tags.append(row[1])
label_name = model.get_outputs()[0].name
probs = model.run([label_name], {input.name: image})[0]
result = list(zip(tags, probs[0]))
# rating = max(result[:general_index], key=lambda x: x[1])
general = [item for item in result[general_index:character_index] if item[1] > threshold]
character = [item for item in result[character_index:] if item[1] > character_threshold]
all = character + general
remove = [s.strip() for s in exclude_tags.lower().split(",")]
all = [tag for tag in all if tag[0] not in remove]
res = ("" if trailing_comma else ", ").join(
(item[0].replace("(", "\\(").replace(")", "\\)") + (", " if trailing_comma else "") for item in all)
)
return res
async def download_model(model, client_id, node):
"""Download ONNX model and CSV file from HuggingFace"""
hf_endpoint = os.getenv("HF_ENDPOINT", defaults["HF_ENDPOINT"])
if not hf_endpoint.startswith("https://"):
hf_endpoint = f"https://{hf_endpoint}"
if hf_endpoint.endswith("/"):
hf_endpoint = hf_endpoint.rstrip("/")
url = models[model]
url = url.replace("{HF_ENDPOINT}", hf_endpoint)
url = f"{url}/resolve/main/"
async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session:
async def update_callback(perc):
message = ""
if perc < 100:
message = f"Downloading {model}"
update_node_status(client_id, node, message, perc)
try:
await download_to_file(
f"{url}model.onnx", os.path.join(models_dir, f"{model}.onnx"), update_callback, session=session
)
await download_to_file(
f"{url}selected_tags.csv", os.path.join(models_dir, f"{model}.csv"), update_callback, session=session
)
except aiohttp.client_exceptions.ClientConnectorError as err:
print(
"Unable to download model. Download files manually or try using a HF mirror/proxy website by setting the environment variable HF_ENDPOINT=https://....."
)
raise
update_node_status(client_id, node, None)
return web.Response(status=200)
class WD14Tagger:
@classmethod
def INPUT_TYPES(s):
extra = [name for name, _ in (os.path.splitext(m) for m in get_installed_models()) if name not in known_models]
models = known_models + extra
return {
"required": {
"image": ("IMAGE",),
"model": (models, {"default": defaults["model"]}),
"threshold": ("FLOAT", {"default": defaults["threshold"], "min": 0.0, "max": 1, "step": 0.05}),
"character_threshold": (
"FLOAT",
{"default": defaults["character_threshold"], "min": 0.0, "max": 1, "step": 0.05},
),
"replace_underscore": ("BOOLEAN", {"default": defaults["replace_underscore"]}),
"trailing_comma": ("BOOLEAN", {"default": defaults["trailing_comma"]}),
"exclude_tags": ("STRING", {"default": defaults["exclude_tags"]}),
}
}
RETURN_TYPES = ("STRING",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "tag"
OUTPUT_NODE = True
CATEGORY = "image"
def tag(
self,
image,
model,
threshold,
character_threshold,
exclude_tags="",
replace_underscore=False,
trailing_comma=False,
):
tensor = image * 255
tensor = np.array(tensor, dtype=np.uint8)
pbar = comfy.utils.ProgressBar(tensor.shape[0])
tags = []
for i in range(tensor.shape[0]):
image = Image.fromarray(tensor[i])
tags.append(
wait_for_async(
lambda: tag(
image, model, threshold, character_threshold, exclude_tags, replace_underscore, trailing_comma
)
)
)
pbar.update(1)
return (tags,)
File diff suppressed because it is too large Load Diff
View File
+161
View File
@@ -0,0 +1,161 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el } from "../../../../../../../../../../../../../../../../scripts/ui.js";
const linkSetting = (name, setter, value, attrs) => {
const link = $el("a", {
href: attrs.href || "#",
target: attrs.target || "_blank",
textContent: attrs.text || "Open Link",
style: {
color: "#007bff",
textDecoration: "underline",
cursor: "pointer",
display: "block",
padding: "8px 0",
},
});
return link;
};
app.registerExtension({
name: "sn0w.CustomCharacterNode",
init() {
app.ui.settings.addSetting({
id: "sn0w.CharacterSettings.ManageVisibleSeries",
name: "Manage Visible Series",
type: linkSetting,
attrs: {
href: "/sn0w/series_selector",
target: "_blank",
text: "Manage Visible Series",
},
});
},
async setup() {
try {
const res = await api.fetchApi("/sn0w/auto_hide_notice");
const data = await res.json();
if (data && data.count > 0) {
const names = (data.characters || []).slice(0, 5).join(", ");
const more = data.count > 5 ? ` (+${data.count - 5} more)` : "";
app.extensionManager.toast.add({
severity: "info",
summary: `Auto-hid ${data.count} new character${data.count !== 1 ? "s" : ""}`,
detail: `Hidden in series with hidden characters: ${names}${more}`,
life: 8000,
});
}
} catch (e) {
console.warn("auto_hide_notice failed", e);
}
const existingContextMenu = LiteGraph.ContextMenu;
LiteGraph.ContextMenu = function (values, options) {
if (values[values.length - 1] != "SN0W_CHARACTER_SELECTOR") {
return existingContextMenu.call(this, values, options);
}
const originalCallback = options.callback;
const newCallback = (item, options) => {
if (originalCallback && item) {
originalCallback(item.content, options);
}
};
// Pre-define toString functions to avoid creating closures in loops
const simpleToString = function () {
return this.content;
};
const submenuToString = function () {
if (this.submenu && this.submenu.options) {
const parts = [];
for (let i = 0; i < this.submenu.options.length; i++) {
parts.push(this.submenu.options[i].toString());
}
return `${this.content}::{${parts.join("|")}}`;
}
return this.content;
};
// Build tree structure (optimized with direct property access)
const menuTree = {};
for (let i = 1; i < values.length; i++) {
const parts = values[i].split("|");
let current = menuTree;
const lastIdx = parts.length - 1;
for (let j = 0; j < lastIdx; j++) {
const part = parts[j];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
if (values[i] === "SN0W_CHARACTER_SELECTOR") {
continue; // Skip the special identifier
}
current[parts[lastIdx]] = values[i];
}
// Build menu items recursively
const buildMenu = (tree, depth) => {
const keys = Object.keys(tree).sort();
const items = new Array(keys.length);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const sub = tree[key];
if (typeof sub === "string") {
// Leaf node - extract last part directly
const lastPipeIdx = sub.lastIndexOf("|");
const lastPart = lastPipeIdx >= 0 ? sub.substring(lastPipeIdx + 1) : sub;
items[i] = {
content: lastPart,
disabled: false,
callback: newCallback,
toString: simpleToString,
};
} else {
// Submenu node
const keyItem = {
content: key,
disabled: false,
callback: newCallback,
toString: simpleToString,
};
const submenu = {
options: buildMenu(sub, depth + 1),
};
if (depth > 0) {
submenu.options.unshift(keyItem);
}
items[i] = {
content: key,
disabled: false,
has_submenu: true,
submenu: submenu,
toString: submenuToString,
};
}
}
return items;
};
const noneItem = {
content: "None",
disabled: false,
toString: simpleToString,
callback: newCallback,
};
const hierarchicalValues = [noneItem].concat(buildMenu(menuTree, 0));
options.callback = undefined;
existingContextMenu.call(this, hierarchicalValues, options);
};
},
});
-112
View File
@@ -1,112 +0,0 @@
import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
async function fetchExtensionSettings() {
return SettingUtils.fetchApi("/extensions/ComfyUI-Sn0w-Scripts/settings/sn0w_settings.json");
}
const sn0wSettings = await fetchExtensionSettings();
/*
const settingUtils = new SettingUtils();
function updateLoraSorting(graphCanvas) {
setTimeout(() => {
let loraLoaders = sn0wSettings['loraLoaders'];
loraLoaders.forEach((loraLoader) => {
let node = null;
for (const graphNode of Object.values(graphCanvas.graph._nodes)) {
if (graphNode.type === loraLoader) {
node = graphNode;
break;
}
}
if (node) {
settingUtils.refreshComboInSingleNode(graphCanvas, loraLoader);
}
})
}, 50);
}
*/
const defaultValue = 'ExampleName1:Value1\nExampleName2:Value2';
const tooltip = 'Enter each name-value pair on a new line, separated by a colon (:).';
const settingsMap = {
'sn0w.CustomLoraLoaders1.5': 'loras_15',
'sn0w.CustomLoraLoadersXL': 'loras_xl',
'sn0w.CustomLoraLoaders3': 'loras_3',
};
const loraLoadersSettingsDefinitions = [
{
id: 'sn0w.CustomLoraLoadersXL',
name: '[Sn0w] Custom Lora Loaders SDXL',
type: SettingUtils.createMultilineSetting,
defaultValue: defaultValue,
attrs: { tooltip: tooltip },
},
{
id: 'sn0w.CustomLoraLoaders1.5',
name: '[Sn0w] Custom Lora Loaders SD 1.5',
type: SettingUtils.createMultilineSetting,
defaultValue: defaultValue,
attrs: { tooltip: tooltip },
},
{
id: 'sn0w.CustomLoraLoaders3',
name: '[Sn0w] Custom Lora Loaders SD3',
type: SettingUtils.createMultilineSetting,
defaultValue: defaultValue,
attrs: { tooltip: tooltip },
},
];
const loraSettingsDefinitions = [
{
id: 'sn0w.SortLorasBy',
name: '[Sn0w] Sort Loras By',
defaultValue: "alphabetical",
options: [
{ text: 'Alphabetical', value: 'alphabetical' },
{ text: 'Last Changed', value: 'last_changed' },
],
type: 'combo',
},
{
id: 'sn0w.RemoveLoraPath',
name: '[Sn0w] Remove Lora Path',
defaultValue: false,
type: 'boolean',
},
]
const registerSetting = (settingDefinition) => {
const extension = {
name: settingDefinition.id,
init() {
const setting = app.ui.settings.addSetting({
id: settingDefinition.id,
name: settingDefinition.name,
type: settingDefinition.type,
defaultValue: settingDefinition.defaultValue,
attrs: settingDefinition.attrs,
options: settingDefinition.options,
onChange: settingDefinition.onChange,
});
},
};
app.registerExtension(extension);
};
// Register settings
loraLoadersSettingsDefinitions.forEach((setting) => {
if (sn0wSettings['loaders_enabled'].includes(settingsMap[setting.id])) {
registerSetting(setting);
}
});
loraSettingsDefinitions.forEach((setting) => {
registerSetting(setting);
});
+2 -9
View File
@@ -1,25 +1,18 @@
import { SettingUtils } from './sn0w.js'; import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js'; import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
const settingUtils = new SettingUtils(); const settingUtils = new SettingUtils();
function updateSorting(graphCanvas) { function updateSorting(graphCanvas) {
api.fetchApi(`${SettingUtils.API_PREFIX}/update_characters`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
setTimeout(() => { setTimeout(() => {
settingUtils.refreshComboInSingleNode(graphCanvas, 'Character Selector'); settingUtils.refreshComboInSingleNode(graphCanvas, 'Character Selector');
}, 50); }, 50);
} }
const id = 'sn0w.DisableDefaultCharacters'; const id = 'sn0w.CharacterSettings.DisableDefaultCharacters';
const settingDefinition = { const settingDefinition = {
id, id,
name: '[Sn0w] Disable Default Characters', name: 'Disable Default Characters',
defaultValue: false, defaultValue: false,
type: 'boolean', type: 'boolean',
onChange: () => updateSorting(app), onChange: () => updateSorting(app),
+2 -2
View File
@@ -1,9 +1,9 @@
import { app } from '../../../scripts/app.js'; import { app } from '../../../scripts/app.js';
const id = 'sn0w.ExcludedRandomCharacters'; const id = 'sn0w.CharacterSettings.ExcludedRandomCharacters';
const settingDefinition = { const settingDefinition = {
id, id,
name: '[Sn0w] Random Characters from Favourites Only', name: 'Random Characters from Favourites Only',
defaultValue: false, defaultValue: false,
type: 'boolean', type: 'boolean',
}; };
-62
View File
@@ -1,62 +0,0 @@
import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js';
const favouriteCharactersId = 'sn0w.FavouriteCharacters';
let existingList = [];
app.registerExtension({
name: 'sn0w.CharacterContextMenu',
async setup() {
existingList = await SettingUtils.getSetting('sn0w.FavouriteCharacters');
if (!Array.isArray(existingList)) {
existingList = [];
}
const original_getNodeMenuOptions = app.canvas.getNodeMenuOptions;
app.canvas.getNodeMenuOptions = function (node) {
const options = original_getNodeMenuOptions.apply(this, arguments);
if (node.type === 'Character Selector') {
const settingUtils = new SettingUtils();
const nullIndex = options.indexOf(null);
// Check if the filename is in the existingList
const selectedCharacter = node.widgets[0].value;
const isFavourite = existingList.includes(selectedCharacter);
// Create the new menu item
const newMenuItem = {
content: isFavourite ? 'Unfavourite Character ☆' : 'Favourite Character ★',
disabled: false,
callback: () => {
SettingUtils.toggleFavourite(
existingList,
selectedCharacter,
favouriteCharactersId
);
settingUtils.refreshComboInSingleNode(app, 'Character Selector');
},
};
if (nullIndex !== -1) {
options.splice(nullIndex, 0, newMenuItem);
} else {
options.push(newMenuItem);
}
}
return options;
};
var observer = new MutationObserver(function (mutations) {
if (document.contains(document.getElementsByClassName('litecontextmenu')[0])) {
SettingUtils.addStarsToFavourited(existingList);
}
});
observer.observe(document, {
attributes: false,
childList: true,
characterData: false,
subtree: true,
});
},
});
+22
View File
@@ -0,0 +1,22 @@
import { SettingUtils } from "./sn0w.js";
import { app } from "../../../scripts/app.js";
const id = "sn0w.ImpliedTags";
const settingDefinition = {
id,
name: "Implied Tags",
defaultValue: "",
type: SettingUtils.createMultilineSetting,
tooltip: "Enter implied tags, as `tag:implied_tag` on each line. Tags are added by the Prompt Combine node.",
};
let setting;
const extension = {
name: id,
init() {
setting = app.ui.settings.addSetting(settingDefinition);
},
};
app.registerExtension(extension);
+43 -26
View File
@@ -1,40 +1,57 @@
import { SettingUtils } from './sn0w.js'; import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js'; import { api } from '../../../scripts/api.js';
async function updateLoggingLevel() { async function updateLoggingLevel() {
const settingsArray = settingsDefinitions.map(setting => ({
url: setting.id,
defaultValue: setting.defaultValue,
}));
const settings = await SettingUtils.getMultipleSettings(settingsArray);
let loggingArray = []
settingsArray.forEach(setting => {
const settingValueArr = setting.url.split(".");
const settingValue = settingValueArr[settingValueArr.length - 1].toUpperCase();
if (settings[setting.url] == true) {
loggingArray.push(settingValue);
}
});
await api.storeSetting("sn0w.LoggingLevel", loggingArray)
await api.fetchApi(`${SettingUtils.API_PREFIX}/update_logging_level`, { await api.fetchApi(`${SettingUtils.API_PREFIX}/update_logging_level`, {
method: 'GET', method: 'GET',
}); });
SettingUtils.logSn0w(`Logging levels selected:\n${await SettingUtils.getSetting("sn0w.LoggingLevel")}`, "debug")
SettingUtils.setLoggingLevel(loggingArray);
SettingUtils.logSn0w("Logging Levels Updated", "debug", "setting", `Logging levels selected:\n${loggingArray}`);
} }
const debouncedUpdateLoggingLevel = SettingUtils.debounce(updateLoggingLevel, 1000); const debouncedUpdateLoggingLevel = SettingUtils.debounce(updateLoggingLevel, 1000);
const id = 'sn0w.LoggingLevel'; const settingsDefinitions = [
const settingDefinition = { {
id, id: 'sn0w.LoggingLevel.Debug',
name: '[Sn0w] Logging Level', name: 'Debug',
type: SettingUtils.createCheckboxSetting, defaultValue: false,
defaultValue: ['WARNING', 'INFORMATIONAL'], type: 'boolean',
onChange: debouncedUpdateLoggingLevel, onChange: debouncedUpdateLoggingLevel,
attrs: {
options: [
{ label: 'Warnings', value: 'WARNING' },
{ label: 'Informational', value: 'INFORMATIONAL' },
{ label: 'Debug', value: 'DEBUG' },
],
tooltip: '',
}, },
}; {
id: 'sn0w.LoggingLevel.Informational',
let setting; name: 'Informational',
defaultValue: true,
const extension = { type: 'boolean',
name: id, onChange: debouncedUpdateLoggingLevel,
init() {
setting = app.ui.settings.addSetting(settingDefinition);
}, },
}; {
id: 'sn0w.LoggingLevel.Warning',
name: 'Warning',
defaultValue: true,
type: 'boolean',
onChange: debouncedUpdateLoggingLevel,
},
]
app.registerExtension(extension); settingsDefinitions.forEach((setting) => {
SettingUtils.registerSetting(setting);
});
-144
View File
@@ -1,144 +0,0 @@
import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
async function addLoraLoaders(loraLoaders) {
try {
const response = await api.fetchApi(`${SettingUtils.API_PREFIX}/add_lora_loaders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(loraLoaders),
})
const data = await response.json();
SettingUtils.logSn0w(data.message, "informational");
} catch (error) {
SettingUtils.logSn0w(error.message, "error");
}
}
const id = 'sn0w.HighlightFavourite';
const settingDefinition = {
id,
name: '[Sn0w] Highlight Favourite Items',
defaultValue: false,
type: 'boolean',
};
const favouriteLoraId = 'sn0w.FavouriteLoras';
let loraLoaders = ['Load Lora Sn0w', 'LoraLoader'];
let existingList = [];
app.registerExtension({
name: 'sn0w.LoraContextMenu',
init() {
let setting = app.ui.settings.addSetting(settingDefinition);
},
async setup() {
const customLoaderKeys = [
'sn0w.CustomLoraLoadersXL',
'sn0w.CustomLoraLoaders15',
'sn0w.CustomLoraLoaders3',
];
let customLoraLoadersArrays = [];
// Fetch and process each custom loader
for (const key of customLoaderKeys) {
const customLoaders = await SettingUtils.getSetting(key);
let customLoaderArray = [];
if (customLoaders) {
customLoaderArray = customLoaders.split('\n').map((item) => item.split(':')[0]);
}
customLoraLoadersArrays.push(customLoaderArray);
}
existingList = await SettingUtils.getSetting(favouriteLoraId);
if (!Array.isArray(existingList)) {
existingList = [];
}
// Combine the existing custom loaders with the fetched ones
loraLoaders = loraLoaders.concat(...customLoraLoadersArrays);
addLoraLoaders(loraLoaders);
const original_getNodeMenuOptions = app.canvas.getNodeMenuOptions;
app.canvas.getNodeMenuOptions = function (node) {
const options = original_getNodeMenuOptions.apply(this, arguments);
// Collect all Lora widgets.
const loraWidgets = node.widgets.filter(widget => widget.name.includes("lora") && widget.type === "combo");
const totalLoras = loraWidgets.length;
if (loraLoaders.includes(node.type)) {
const settingUtils = new SettingUtils();
const nullIndex = options.indexOf(null);
let optionsArr = []
loraWidgets.forEach(widget => {
const selectedLora = widget.value;
const pathArray = selectedLora.split('\\');
const filename = pathArray[pathArray.length - 1];
if (filename == "None") {
return;
}
const isFavourite = existingList.includes(filename);
const newMenuItem = {
content: (isFavourite ? 'Unfavourite ' : 'Favourite ') + filename.split(".")[0] + (isFavourite ? ' ☆' : ' ★'),
disabled: false,
callback: () => {
SettingUtils.toggleFavourite(existingList, filename, favouriteLoraId);
settingUtils.refreshComboInSingleNode(app, node.title);
},
};
optionsArr.push(newMenuItem);
});
let menuItem
if (optionsArr.length > 0) {
if (totalLoras === 1) {
menuItem = optionsArr[0]
} else if (totalLoras > 1) {
menuItem = {
content: "Favourite",
disabled: false,
has_submenu: true,
submenu: {
options: optionsArr
}
}
}
} else {
menuItem = {
content: "No lora selected",
disabled: true,
}
}
if (nullIndex !== -1) {
options.splice(nullIndex, 0, menuItem);
} else {
options.push(menuItem);
}
}
return options;
};
var observer = new MutationObserver(function (mutations) {
if (document.contains(document.getElementsByClassName('litecontextmenu')[0])) {
SettingUtils.addStarsToFavourited(existingList);
}
});
observer.observe(document, {
attributes: false,
childList: true,
characterData: false,
subtree: true,
});
},
});
-23
View File
@@ -1,23 +0,0 @@
import { app } from '../../../scripts/app.js';
const id = 'sn0w.LoraFolderMinDistance';
const settingDefinition = {
id,
name: '[Sn0w] Max difference in Lora Loading',
defaultValue: 5,
min: 0,
max: 20,
step: 1,
type: 'slider',
};
let setting;
const extension = {
name: id,
init() {
setting = app.ui.settings.addSetting(settingDefinition);
},
};
app.registerExtension(extension);
+63
View File
@@ -0,0 +1,63 @@
import { app } from "../../../scripts/app.js";
import { SettingUtils } from "./sn0w.js";
const LOADER_INFO_ROUTE = "/sn0w/lora_loader_info";
const PRESETS_ROUTE = "/sn0w/lora_presets";
let loaderInfo = {};
const suffixForIndex = (index) => String.fromCharCode(97 + index);
const collectPreset = (node, info) => {
const loras = [];
for (let i = 0; i < info.combos; i++) {
const suffix = suffixForIndex(i);
const loraWidget = node.widgets.find((w) => w.name === `lora_${suffix}`);
const strengthWidget = node.widgets.find((w) => w.name === `lora_strength_${suffix}`);
if (!loraWidget) break;
if (loraWidget.value && loraWidget.value !== "None") {
loras.push({ lora: loraWidget.value, strength: strengthWidget?.value ?? 1.0 });
}
}
return loras;
};
const savePreset = async (node, info) => {
const name = window.prompt("Preset name:", "");
if (!name || !name.trim()) return;
const trimmedName = name.trim();
const loras = collectPreset(node, info);
const result = await SettingUtils.fetchApi(PRESETS_ROUTE, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: trimmedName, path: info.path, loras }),
});
if (result?.status !== "ok") return;
const settingUtils = new SettingUtils();
for (const [typeName, typeInfo] of Object.entries(loaderInfo)) {
if (typeInfo.path === info.path) {
settingUtils.refreshComboInSingleNode(app, typeName);
}
}
};
app.registerExtension({
name: "sn0w.LoraPresets",
async setup() {
loaderInfo = (await SettingUtils.fetchApi(LOADER_INFO_ROUTE)) || {};
},
beforeRegisterNodeDef(nodeType) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
if (getExtraMenuOptions) getExtraMenuOptions.apply(this, arguments);
const info = loaderInfo[this.type] || loaderInfo[this.comfyClass];
if (!info) return;
options.unshift({
content: "Save Current as Preset",
callback: () => savePreset(this, info),
});
};
},
});
+99
View File
@@ -0,0 +1,99 @@
import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js';
async function fetchExtensionSettings() {
return SettingUtils.fetchApi("/extensions/ComfyUI-Sn0w-Scripts/settings/sn0w_settings.json");
}
const settingUtils = new SettingUtils();
async function updateLoraSorting(graphCanvas) {
const tempSn0wSettings = await fetchExtensionSettings();
setTimeout(() => {
let loraLoaders = tempSn0wSettings['loraLoaders'];
if (loraLoaders != undefined) {
loraLoaders.forEach((loraLoader) => {
let node = null;
for (const graphNode of Object.values(graphCanvas.graph._nodes)) {
if (graphNode.type === loraLoader) {
settingUtils.refreshComboInSingleNode(graphCanvas, loraLoader);
}
}
})
}
}, 50);
}
const defaultValue = 'ExampleName1:Value1\nExampleName2:Value2';
const tooltip = 'Enter each name-value pair on a new line, separated by a colon (:).';
const settingsMap = {
'sn0w.CustomLoraLoaders': 'loras_15',
'sn0w.CustomLoraLoaders.XL': 'loras_xl',
'sn0w.CustomLoraLoaders.3': 'loras_3',
};
const loraLoadersSettingsDefinitions = [
{
id: 'sn0w.CustomLoraLoaders',
name: 'Custom Lora Loaders SD 1.5',
type: SettingUtils.createMultilineSetting,
defaultValue: defaultValue,
tooltip: tooltip,
},
{
id: 'sn0w.CustomLoraLoaders.XL',
name: 'Custom Lora Loaders SDXL',
type: SettingUtils.createMultilineSetting,
defaultValue: defaultValue,
tooltip: tooltip,
},
{
id: 'sn0w.CustomLoraLoaders.3',
name: 'Custom Lora Loaders SD3',
type: SettingUtils.createMultilineSetting,
defaultValue: defaultValue,
tooltip: tooltip,
},
];
const loraSettingsDefinitions = [
{
id: 'sn0w.LoraSettings.SortLorasBy',
name: 'Sort Loras By',
defaultValue: "alphabetical",
options: [
{ text: 'Alphabetical', value: 'alphabetical' },
{ text: 'Last Changed', value: 'last_changed' },
],
type: 'combo',
onChange: () => updateLoraSorting(app),
},
{
id: 'sn0w.LoraSettings.RemoveLoraPath',
name: 'Remove Lora Path',
defaultValue: false,
type: 'boolean',
onChange: () => updateLoraSorting(app),
},
{
id: 'sn0w.LoraSettings.LoraFolderMinDistance',
name: 'Max difference in Lora Loading',
defaultValue: 5,
min: 0,
max: 20,
step: 1,
type: 'slider',
}
]
const sn0wSettings = await fetchExtensionSettings();
// Register settings
loraLoadersSettingsDefinitions.forEach((setting) => {
if (sn0wSettings['loaders_enabled'].includes(settingsMap[setting.id])) {
SettingUtils.registerSetting(setting);
}
});
loraSettingsDefinitions.forEach((setting) => {
SettingUtils.registerSetting(setting);
});
-21
View File
@@ -1,21 +0,0 @@
import { app } from '../../../scripts/app.js';
const id = 'sn0w.PromptFormat';
const settingDefinition = {
id,
name: '[Sn0w] Animagine Prompt Style',
defaultValue: false,
type: 'boolean',
tooltip: 'Puts 1girl/ 1boy at the front of prompts.',
};
let setting;
const extension = {
name: id,
init() {
setting = app.ui.settings.addSetting(settingDefinition);
},
};
app.registerExtension(extension);
+7 -26
View File
@@ -1,36 +1,17 @@
import { app } from '../../../scripts/app.js'; import { app } from "../../../scripts/app.js";
app.registerExtension({ app.registerExtension({
name: 'sn0w.SimpleKSampler', name: "sn0w.SimpleKSampler",
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'Sn0w KSampler') { if (nodeData.name === "Sn0w KSampler") {
const onConnectInput = nodeType.prototype.onConnectInput;
nodeType.prototype.onConnectInput = function (targetSlot, type) {
if (targetSlot === 3 || targetSlot === 4) {
if (type == 'STRING' || type == 'CONDITIONING') {
this.inputs[targetSlot].color_on =
app.canvas.default_connection_color_byType[type];
} else {
console.error(
`The input type has to be STRING or CONDITIONING, it cannot be ${type}.`
);
this.inputs[targetSlot].color_on =
app.canvas.default_connection_color_byType['VAE'];
}
}
onConnectInput.apply(this, arguments);
};
// Remove old array input types to prevernt new frontend complaining.
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
this.inputs[3].type = '*'; this.inputs[3].type = "*";
this.inputs[4].type = '*'; this.inputs[4].type = "*";
}; };
nodeType.prototype.onConfigure = function () { nodeType.prototype.onConfigure = function () {
this.inputs[3].type = '*'; this.inputs[3].type = "*";
this.inputs[4].type = '*'; this.inputs[4].type = "*";
}; };
} }
}, },
+234 -222
View File
@@ -1,38 +1,70 @@
import { api } from '../../../scripts/api.js'; import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el } from "../../../scripts/ui.js";
export class SettingUtils { export class SettingUtils {
static API_PREFIX = '/sn0w'; static API_PREFIX = "/sn0w";
static cachedLoggingLevel = null;
static observingContextMenu = false;
static contextMenuList = [];
// SETTINGS static async getSetting(url, defaultValue = null) {
static async getSetting(url) {
try { try {
const settingUrl = `/settings/${url}`; const settingUrl = `/settings/${url}`;
const response = await fetch(settingUrl); const response = await fetch(settingUrl);
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok'); return defaultValue;
} }
const data = await response.json(); const data = await response.json();
if (data == null) {
return defaultValue;
}
SettingUtils.logSn0w(`Got setting: ${url}`, "debug", "setting", data);
return data; return data;
} catch (error) { } catch (error) {
console.error('There was a problem with the fetch operation:', error); console.error("There was a problem with the fetch operation:", error);
return defaultValue;
}
}
static async getMultipleSettings(settingsArray) {
// Create an array of promises using getSetting with the corresponding default values
const promises = settingsArray.map(({ url, defaultValue }) => this.getSetting(url, defaultValue));
try {
const results = await Promise.all(promises);
// Convert the results to a Map or a plain object where each URL is the key
const settingsMap = {};
settingsArray.forEach(({ url }, index) => {
settingsMap[url] = results[index];
});
return settingsMap;
} catch (error) {
console.error("There was a problem with one of the fetch operations:", error);
return null; return null;
} }
} }
static async fetchApi(route, options) { static async fetchApi(route, options) {
if (!options) { if (!options) {
options = {}; options = {};
} }
if (!options.headers) { if (!options.headers) {
options.headers = {}; options.headers = {};
} }
const url = `${location.protocol}//${location.host}${route}`;
options.cache = "no-store";
try { try {
const response = await fetch(url, options); const response = await fetch(route, options);
const data = await response.json(); const data = await response.json();
return data; return data;
} catch (error) { } catch (error) {
console.error('There was a problem with the fetch operation:', error); console.error("There was a problem with the fetch operation:", error);
return null;
} }
} }
@@ -52,75 +84,70 @@ export class SettingUtils {
// Store the updated list back in the settings // Store the updated list back in the settings
await api.storeSetting(setting, existingList); await api.storeSetting(setting, existingList);
} catch (error) { } catch (error) {
console.error('Error updating settings:', error); console.error("Error updating settings:", error);
} }
} }
static registerSetting = (settingDefinition) => {
const extension = {
name: settingDefinition.id,
init() {
const setting = app.ui.settings.addSetting({
...settingDefinition, // Spread all properties from settingDefinition
});
},
};
app.registerExtension(extension);
};
static createMultilineSetting(name, setter, value, attrs) { static createMultilineSetting(name, setter, value, attrs) {
const tr = document.createElement('tr'); // Ensure that tooltip has a default value if not provided
const tdLabel = document.createElement('td'); const htmlID = `${name.replaceAll(" ", "").replaceAll("[", "").replaceAll("]", "-")}`;
const tdInput = document.createElement('td');
const label = document.createElement('label');
const textarea = document.createElement('textarea');
// Generate a unique ID for associating the label with the textarea // Create the textarea element
const uniqueId = `${name.replaceAll(' ', '').replaceAll('[', '').replaceAll(']', '-')}`; const textarea = $el("textarea", {
label.setAttribute('for', uniqueId); value,
label.textContent = name; id: htmlID,
oninput: (e) => {
adjustHeight();
},
className: "p-inputtext",
style: {
width: "100%",
resize: "none",
},
...attrs,
});
textarea.id = uniqueId;
textarea.value = value;
const maxLines = 10; const maxLines = 10;
// Function to set the height based on the number of lines
const adjustHeight = () => { const adjustHeight = () => {
textarea.style.height = ''; // Allow to shrink requestAnimationFrame(() => {
const lines = textarea.value.split('\n').length; const parentDiv = textarea.parentElement;
const scrollHeight = textarea.scrollHeight + 3; if (parentDiv != null) {
if (lines > maxLines) { parentDiv.style.width = "100%";
const height = (scrollHeight / lines) * maxLines
textarea.setAttribute( const id = parentDiv.id;
'style', if (id != null) {
`width: 100%; height: ${height}px; resize: none;` api.storeSetting(id, textarea.value);
); }
return; }
}
textarea.setAttribute('style', `width: 100%; height: ${scrollHeight}px; resize: none;`); textarea.style.height = ""; // Allow to shrink
const lines = textarea.value.split("\n").length;
const scrollHeight = textarea.scrollHeight + 3;
if (lines > maxLines) {
const height = (scrollHeight / lines) * maxLines;
textarea.setAttribute("style", `width: 100%; height: ${height}px; resize: none;`);
return;
}
textarea.setAttribute("style", `width: 100%; height: ${scrollHeight}px; resize: none;`);
});
}; };
adjustHeight(); adjustHeight();
textarea.onchange = () => setter(textarea.value);
textarea.addEventListener('input', adjustHeight);
// Apply additional attributes return textarea;
for (const [key, val] of Object.entries(attrs)) {
textarea.setAttribute(key, val);
}
tdLabel.appendChild(label);
tdInput.appendChild(textarea);
tr.appendChild(tdLabel);
tr.appendChild(tdInput);
const tooltip = attrs['tooltip'];
// Setting tooltip if provided
if (tooltip != '' && tooltip != undefined) {
tr.title = tooltip;
label.className = 'comfy-tooltip-indicator';
}
// Update height when settings are opened
const dialog = document.getElementById('comfy-settings-dialog');
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'open') {
adjustHeight();
}
});
});
observer.observe(dialog, { attributes: true });
return tr;
} }
static createCheckboxSetting(name, setter, value, attrs) { static createCheckboxSetting(name, setter, value, attrs) {
@@ -128,36 +155,29 @@ export class SettingUtils {
const threshold = 10; const threshold = 10;
const hideThreshold = 5; const hideThreshold = 5;
const tr = document.createElement('tr'); const tr = document.createElement("tr");
const tdLabel = document.createElement('td'); const tdInput = document.createElement("td");
const tdInput = document.createElement('td');
const label = document.createElement('label');
// Generate a unique ID for the setting container // Generate a unique ID for the setting container
const uniqueId = `${name.replaceAll(' ', '').replaceAll('[', '').replaceAll(']', '-')}`; const uniqueId = `${name.replaceAll(" ", "").replaceAll("[", "").replaceAll("]", "-")}`;
label.setAttribute('for', uniqueId);
label.textContent = name;
// Checkbox container // Checkbox container
const checkboxContainer = document.createElement('div'); const checkboxContainer = document.createElement("div");
checkboxContainer.id = uniqueId; checkboxContainer.id = uniqueId;
//checkboxContainer.style.padding = "5px"; //checkboxContainer.style.padding = "5px";
checkboxContainer.style.display = 'block'; // Ensure it is initially visible checkboxContainer.style.display = "block"; // Ensure it is initially visible
// Hide button // Hide button
if (attrs.options.length > hideThreshold) { if (attrs.options.length > hideThreshold) {
const hideButton = document.createElement('button'); const hideButton = document.createElement("button");
hideButton.textContent = 'Show'; hideButton.textContent = "Show";
checkboxContainer.style.display = 'none'; checkboxContainer.style.display = "none";
hideButton.onclick = () => { hideButton.onclick = () => {
checkboxContainer.style.display = checkboxContainer.style.display = checkboxContainer.style.display === "none" ? "block" : "none";
checkboxContainer.style.display === 'none' ? 'block' : 'none'; hideButton.textContent = checkboxContainer.style.display === "none" ? "Show" : "Hide";
hideButton.textContent = hideButton.style.marginBottom = checkboxContainer.style.display === "none" ? "0px" : "5px";
checkboxContainer.style.display === 'none' ? 'Show' : 'Hide';
hideButton.style.marginBottom =
checkboxContainer.style.display === 'none' ? '0px' : '5px';
}; };
hideButton.style.width = '100%'; hideButton.style.width = "100%";
// Append the hide button before the checkboxes // Append the hide button before the checkboxes
tdInput.appendChild(hideButton); tdInput.appendChild(hideButton);
@@ -169,29 +189,29 @@ export class SettingUtils {
// Check if the number of options exceeds the threshold // Check if the number of options exceeds the threshold
if (attrs.options.length > threshold) { if (attrs.options.length > threshold) {
// Create search textbox // Create search textbox
const searchContainer = document.createElement('div'); const searchContainer = document.createElement("div");
const searchInput = document.createElement('input'); const searchInput = document.createElement("input");
searchInput.type = 'text'; searchInput.type = "text";
searchInput.placeholder = 'Search...'; searchInput.placeholder = "Search...";
searchInput.style.width = '100%'; searchInput.style.width = "100%";
searchContainer.appendChild(searchInput); searchContainer.appendChild(searchInput);
checkboxContainer.appendChild(searchContainer); checkboxContainer.appendChild(searchContainer);
// Create "Check All" checkbox // Create "Check All" checkbox
const checkAllContainer = document.createElement('div'); const checkAllContainer = document.createElement("div");
const checkAllCheckbox = document.createElement('input'); const checkAllCheckbox = document.createElement("input");
const checkAllLabel = document.createElement('label'); const checkAllLabel = document.createElement("label");
const checkAllId = `${uniqueId}-check-all`; const checkAllId = `${uniqueId}-check-all`;
checkAllCheckbox.type = 'checkbox'; checkAllCheckbox.type = "checkbox";
checkAllCheckbox.id = checkAllId; checkAllCheckbox.id = checkAllId;
checkAllCheckbox.onchange = () => { checkAllCheckbox.onchange = () => {
const allCheckboxes = checkboxContainer.querySelectorAll('input[type="checkbox"]'); const allCheckboxes = checkboxContainer.querySelectorAll('input[type="checkbox"]');
allCheckboxes.forEach((checkbox) => { allCheckboxes.forEach((checkbox) => {
if (checkbox !== checkAllCheckbox) { if (checkbox !== checkAllCheckbox) {
checkbox.checked = checkAllCheckbox.checked; checkbox.checked = checkAllCheckbox.checked;
const optionValue = checkbox.id.split('-').pop(); const optionValue = checkbox.id.split("-").pop();
if (checkAllCheckbox.checked && !values.includes(optionValue)) { if (checkAllCheckbox.checked && !values.includes(optionValue)) {
values.push(optionValue); values.push(optionValue);
} else if (!checkAllCheckbox.checked && values.includes(optionValue)) { } else if (!checkAllCheckbox.checked && values.includes(optionValue)) {
@@ -202,28 +222,28 @@ export class SettingUtils {
setter(values); // Update the setting with the current array of selected values setter(values); // Update the setting with the current array of selected values
}; };
checkAllLabel.setAttribute('for', checkAllId); checkAllLabel.setAttribute("for", checkAllId);
checkAllLabel.textContent = 'Check All'; checkAllLabel.textContent = "Check All";
checkAllLabel.style.marginRight = '10px'; checkAllLabel.style.marginRight = "10px";
checkAllContainer.style.borderBottom = '1px solid var(--border-color)'; checkAllContainer.style.borderBottom = "1px solid var(--border-color)";
checkAllContainer.style.paddingBottom = '4px'; checkAllContainer.style.paddingBottom = "4px";
checkAllContainer.style.marginBottom = '5px'; checkAllContainer.style.marginBottom = "5px";
checkAllContainer.appendChild(checkAllCheckbox); checkAllContainer.appendChild(checkAllCheckbox);
checkAllContainer.appendChild(checkAllLabel); checkAllContainer.appendChild(checkAllLabel);
checkboxContainer.appendChild(checkAllContainer); checkboxContainer.appendChild(checkAllContainer);
// Filter checkboxes based on search input // Filter checkboxes based on search input
searchInput.addEventListener('input', () => { searchInput.addEventListener("input", () => {
const searchText = searchInput.value.toLowerCase(); const searchText = searchInput.value.toLowerCase();
attrs.options.forEach((option) => { attrs.options.forEach((option) => {
const checkbox = document.getElementById(`${uniqueId}-${option.value}`); const checkbox = document.getElementById(`${uniqueId}-${option.value}`);
const label = checkbox.nextSibling; const label = checkbox.nextSibling;
if (option.label.toLowerCase().includes(searchText)) { if (option.label.toLowerCase().includes(searchText)) {
checkbox.parentElement.style.display = ''; checkbox.parentElement.style.display = "";
} else { } else {
checkbox.parentElement.style.display = 'none'; checkbox.parentElement.style.display = "none";
} }
}); });
}); });
@@ -231,11 +251,11 @@ export class SettingUtils {
// Create checkboxes based on the options provided in attrs // Create checkboxes based on the options provided in attrs
attrs.options.forEach((option) => { attrs.options.forEach((option) => {
const checkbox = document.createElement('input'); const checkbox = document.createElement("input");
const checkboxLabel = document.createElement('label'); const checkboxLabel = document.createElement("label");
const checkboxId = `${uniqueId}-${option.value}`; const checkboxId = `${uniqueId}-${option.value}`;
checkbox.type = 'checkbox'; checkbox.type = "checkbox";
checkbox.id = checkboxId; checkbox.id = checkboxId;
checkbox.checked = values.includes(option.value); checkbox.checked = values.includes(option.value);
checkbox.onchange = () => { checkbox.onchange = () => {
@@ -253,11 +273,11 @@ export class SettingUtils {
} }
}; };
checkboxLabel.setAttribute('for', checkboxId); checkboxLabel.setAttribute("for", checkboxId);
checkboxLabel.textContent = option.label; checkboxLabel.textContent = option.label;
checkboxLabel.style.marginRight = '10px'; checkboxLabel.style.marginRight = "10px";
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.appendChild(checkbox); wrapper.appendChild(checkbox);
wrapper.appendChild(checkboxLabel); wrapper.appendChild(checkboxLabel);
checkboxContainer.appendChild(wrapper); checkboxContainer.appendChild(wrapper);
@@ -265,8 +285,6 @@ export class SettingUtils {
tdInput.appendChild(checkboxContainer); tdInput.appendChild(checkboxContainer);
tdLabel.appendChild(label);
tr.appendChild(tdLabel);
tr.appendChild(tdInput); tr.appendChild(tdInput);
const tooltip = attrs.tooltip; const tooltip = attrs.tooltip;
@@ -274,15 +292,15 @@ export class SettingUtils {
// Set tooltip if provided // Set tooltip if provided
if (tooltip) { if (tooltip) {
tr.title = tooltip; tr.title = tooltip;
label.className = 'comfy-tooltip-indicator'; label.className = "comfy-tooltip-indicator";
} }
return tr; return tr;
} }
// WIDGETS // WIDGETS
static hideWidget(node, widget, suffix = '') { static hideWidget(node, widget, suffix = "") {
const CONVERTED_TYPE = 'converted-widget'; const CONVERTED_TYPE = "converted-widget";
if (widget.type?.startsWith(CONVERTED_TYPE)) return; if (widget.type?.startsWith(CONVERTED_TYPE)) return;
widget.origType = widget.type; widget.origType = widget.type;
widget.origComputeSize = widget.computeSize; widget.origComputeSize = widget.computeSize;
@@ -305,7 +323,7 @@ export class SettingUtils {
// Hide any linked widgets, e.g. seed+seedControl // Hide any linked widgets, e.g. seed+seedControl
if (widget.linkedWidgets) { if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) { for (const w of widget.linkedWidgets) {
hideWidget(node, w, ':' + widget.name); hideWidget(node, w, ":" + widget.name);
} }
} }
} }
@@ -334,12 +352,7 @@ export class SettingUtils {
try { try {
return await ext[method](...args, graphCanvas); return await ext[method](...args, graphCanvas);
} catch (error) { } catch (error) {
console.error( console.error(`Error calling extension '${ext.name}' method '${method}'`, { error }, { extension: ext }, { args });
`Error calling extension '${ext.name}' method '${method}'`,
{ error },
{ extension: ext },
{ args }
);
} }
} }
}) })
@@ -389,16 +402,10 @@ export class SettingUtils {
for (const widgetNum in node.widgets) { for (const widgetNum in node.widgets) {
const widget = node.widgets[widgetNum]; const widget = node.widgets[widgetNum];
if ( if (widget.type == "combo" && def["input"]["required"][widget.name] !== undefined) {
widget.type == 'combo' && widget.options.values = def["input"]["required"][widget.name][0];
def['input']['required'][widget.name] !== undefined
) {
widget.options.values = def['input']['required'][widget.name][0];
if ( if (widget.name != "image" && !widget.options.values.includes(widget.value)) {
widget.name != 'image' &&
!widget.options.values.includes(widget.value)
) {
widget.value = widget.options.values[0]; widget.value = widget.options.values[0];
widget.callback(widget.value); widget.callback(widget.value);
} }
@@ -407,73 +414,7 @@ export class SettingUtils {
} }
} }
await this.#invokeExtensionsAsync('refreshComboInSingleNodeByName', graphCanvas, defs); await this.#invokeExtensionsAsync("refreshComboInSingleNodeByName", graphCanvas, defs);
}
static async addStarsToFavourited(existingList) {
const menuEntries = document.querySelectorAll('.litemenu-entry');
const highlightLora = await SettingUtils.getSetting('sn0w.HighlightFavourite');
const root = document.documentElement;
const comfyMenuBgColor = SettingUtils.hexToRgb(
getComputedStyle(root).getPropertyValue('--comfy-menu-bg').trim()
);
// Function to check and update the background color
const checkAndUpdateBackgroundColor = (entry, currentBgColor) => {
if (highlightLora && currentBgColor == comfyMenuBgColor) {
entry.setAttribute('style', 'background-color: green !important');
// Ensure the entry uses flexbox for alignment
entry.style.display = 'flex';
entry.style.alignItems = 'center';
}
};
const observerConfig = { attributes: true, attributeFilter: ['style'] };
// Create the observer
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const entry = mutation.target;
const currentBgColor = window.getComputedStyle(entry).backgroundColor;
checkAndUpdateBackgroundColor(entry, currentBgColor);
}
});
});
menuEntries.forEach((entry) => {
const value = entry.getAttribute('data-value');
let filename = value;
if (value !== null && value.includes('\\')) {
const pathArray = value.split('\\');
filename = pathArray[pathArray.length - 1];
}
if (existingList.includes(filename)) {
// Create star element
const star = document.createElement('span');
star.innerHTML = '★';
star.style.marginLeft = 'auto'; // Push the star to the right
star.style.alignSelf = 'center'; // Center the star vertically
// Ensure the entry uses flexbox for alignment
entry.style.display = 'flex';
entry.style.alignItems = 'center';
// Check if star is already added to avoid duplication
if (!entry.querySelector('span')) {
entry.appendChild(star);
}
// Initial background color check
const currentBgColor = window.getComputedStyle(entry).backgroundColor;
checkAndUpdateBackgroundColor(entry, currentBgColor);
// Attach the observer to the entry
observer.observe(entry, observerConfig);
}
});
} }
// MISC // MISC
@@ -487,21 +428,92 @@ export class SettingUtils {
}; };
} }
static async logSn0w(message, type) { static leadingEdgeDebounce(func, wait) {
const loggingLevels = await SettingUtils.getSetting("sn0w.LoggingLevel"); let timeout;
let lastCallTime = 0;
return function (...args) {
const now = Date.now();
// If the last call was longer ago than the wait period, reset the timeout
if (now - lastCallTime > wait) {
lastCallTime = now;
func.apply(this, args); // Call the function immediately
}
clearTimeout(timeout); // Clear any previous timeout
// Set a new timeout that will reset `lastCallTime` after the wait period
timeout = setTimeout(() => {
lastCallTime = 0;
}, wait);
};
}
static getLuminance(hexColor) {
// Convert hex to RGB
const rgb = parseInt(hexColor.slice(1), 16); // Remove "#" and convert to integer
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = (rgb >> 0) & 0xff;
// Convert RGB to relative luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance;
}
// Function to choose black or white text color based on luminance
static getTextColorBasedOnBg(bgColor) {
const luminance = SettingUtils.getLuminance(bgColor);
return luminance > 0.5 ? "#000000" : "#FFFFFF"; // Black text for bright bg, white text for dark bg
}
static setLoggingLevel(loggingLevel) {
this.cachedLoggingLevel = loggingLevel;
}
static async logSn0w(message, type, category = "", detailMessage = "") {
let loggingLevels = this.cachedLoggingLevel;
if (loggingLevels == null) {
loggingLevels = await SettingUtils.getSetting("sn0w.LoggingLevel");
}
const alwaysLog = ["emergency", "alert", "critical", "error"];
const logLevel = type.toLowerCase();
if (!alwaysLog.includes(logLevel) && !loggingLevels.includes(type.toUpperCase())) {
return;
}
const prefix = "sn0w";
const generalCss = "color: white; padding: 2px 6px 2px 4px; font-weight: lighter;";
let customCSS = generalCss + " border-radius: 3px;";
const logColors = { const logColors = {
"error": "red", error: "#c50f1f",
"warning": "yellow", warning: "#c19c00",
}; };
const alwaysLog = ["emergency", "alert", "critical", "error"] const categoryColors = {
const logLevel = type.toLowerCase(); api: "#c19c00",
const color = logColors[logLevel] || "rgb(136, 23, 152)"; lora: "#3a96dd",
node: "#c19c00",
};
if (alwaysLog.includes(logLevel) || loggingLevels.includes(type.toUpperCase())) { const color = logColors[logLevel] || "#881798";
console.log(`%c[sn0w]`, `color: ${color};`, message); const textColor = SettingUtils.getTextColorBasedOnBg(color);
customCSS += `color: ${textColor};`;
const categoryCSS = category ? `background-color: ${categoryColors[category.toLowerCase()] || "#13a10e"}; color: ${SettingUtils.getTextColorBasedOnBg(categoryColors[category.toLowerCase()] || "#13a10e")}; border-radius: 0 3px 3px 0; margin-left: -5px;` : "";
const logMessage = category
? [`%c${prefix}%c${category}`, `${customCSS.replace("6px", "10px")} background-color: ${color};`, `${generalCss} ${categoryCSS}`, message] // Category
: [`%c${prefix}`, `${customCSS} background-color: ${color};`, message]; // No Category
console.groupCollapsed(...logMessage);
if (detailMessage != "") {
console.log(detailMessage);
} }
console.trace();
console.groupEnd();
} }
static drawSigmas(sigmas) { static drawSigmas(sigmas) {
@@ -510,13 +522,13 @@ export class SettingUtils {
const height = 400; const height = 400;
// Create a canvas element // Create a canvas element
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
// Background // Background
ctx.fillStyle = '#333'; // Dark background ctx.fillStyle = "#333"; // Dark background
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// Normalize sigma values to fit within the canvas height // Normalize sigma values to fit within the canvas height
@@ -529,13 +541,13 @@ export class SettingUtils {
const scaleX = (index) => (index / (sigmas.length - 1)) * width; const scaleX = (index) => (index / (sigmas.length - 1)) * width;
// Draw horizontal grid lines and labels // Draw horizontal grid lines and labels
const gridColor = '#555'; const gridColor = "#555";
const textColor = '#ccc'; const textColor = "#ccc";
const numGridLines = 10; const numGridLines = 10;
const gridSpacing = (height - 40) / numGridLines; const gridSpacing = (height - 40) / numGridLines;
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.font = '10px Arial'; ctx.font = "10px Arial";
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
for (let i = 0; i <= numGridLines; i++) { for (let i = 0; i <= numGridLines; i++) {
@@ -551,7 +563,7 @@ export class SettingUtils {
} }
// Draw the graph line // Draw the graph line
ctx.strokeStyle = 'white'; // Line color ctx.strokeStyle = "white"; // Line color
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(scaleX(0), scaleY(sigmas[0][0])); ctx.moveTo(scaleX(0), scaleY(sigmas[0][0]));
@@ -563,7 +575,7 @@ export class SettingUtils {
ctx.stroke(); ctx.stroke();
// Draw a dot at each sigma point // Draw a dot at each sigma point
ctx.fillStyle = 'red'; // Dot color ctx.fillStyle = "red"; // Dot color
const dotRadius = 4; const dotRadius = 4;
sigmas.forEach((sigma, index) => { sigmas.forEach((sigma, index) => {
ctx.beginPath(); ctx.beginPath();
@@ -572,12 +584,12 @@ export class SettingUtils {
}); });
// Return the canvas content as a Base64 encoded image // Return the canvas content as a Base64 encoded image
return canvas.toDataURL('image/png'); return canvas.toDataURL("image/png");
} }
static hexToRgb(hex) { static hexToRgb(hex) {
// Remove the hash at the start if it's there // Remove the hash at the start if it's there
hex = hex.replace(/^#/, ''); hex = hex.replace(/^#/, "");
// Parse the r, g, b values // Parse the r, g, b values
let bigint = parseInt(hex, 16); let bigint = parseInt(hex, 16);
-41
View File
@@ -1,41 +0,0 @@
import { SettingUtils } from './sn0w.js';
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
const settingUtils = new SettingUtils();
function updateSorting(graphCanvas) {
api.fetchApi(`${SettingUtils.API_PREFIX}/update_characters`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
setTimeout(() => {
settingUtils.refreshComboInSingleNode(graphCanvas, 'Character Selector');
}, 50);
}
const id = 'sn0w.SortCharactersBy';
const settingDefinition = {
id,
name: '[Sn0w] Sort Characters By',
defaultValue: false,
options: [
{ text: 'Alphabetical', value: 'alphabetical' },
{ text: 'Series', value: 'series' },
],
type: 'combo',
onChange: () => updateSorting(app),
};
let setting;
const extension = {
name: id,
init() {
setting = app.ui.settings.addSetting(settingDefinition);
},
};
app.registerExtension(extension);
+8 -504
View File
@@ -1,25 +1,18 @@
import { SettingUtils } from './sn0w.js'; import { SettingUtils } from "./sn0w.js";
import { app } from '../../../scripts/app.js'; import { app } from "../../../scripts/app.js";
import { api } from '../../../scripts/api.js'; import { api } from "../../../scripts/api.js";
app.registerExtension({ app.registerExtension({
name: 'sn0w.Textbox', name: "sn0w.TextboxNode",
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'Copy/Paste Textbox') { if (nodeData.name === "Copy/Paste Textbox") {
// Get textbox text // Get textbox text
nodeType.prototype.getTextboxText = function () { nodeType.prototype.getTextboxText = function () {
const textbox = this.inputEl ? this.inputEl.value : ''; const textbox = this.inputEl ? this.inputEl.value : "";
return textbox; return textbox;
}; };
nodeType.prototype.updateHighlightType = function () { nodeType.prototype.populate = async function () {
const inputEl = this.inputEl.inputEl;
const overlayEl = this.overlayEl;
setTextColors(inputEl, overlayEl);
setTimeout(() => syncText(this.inputEl.inputEl, this.overlayEl), 50);
}
nodeType.prototype.populate = function () {
this.inputEl = this.widgets[0]; this.inputEl = this.widgets[0];
// prettier-ignore // prettier-ignore
@@ -31,506 +24,17 @@ app.registerExtension({
this.addWidget("button", "Paste", "Paste", () => { this.addWidget("button", "Paste", "Paste", () => {
navigator.clipboard.readText().then(text => { navigator.clipboard.readText().then(text => {
this.inputEl.value = text; this.inputEl.value = text;
syncText(this.inputEl.inputEl, this.overlayEl); api.dispatchCustomEvent("update_text_highlight");
}).catch(error => { }).catch(error => {
console.error('Failed to read clipboard contents:', error); console.error('Failed to read clipboard contents:', error);
}); });
}, { serialize: false }); }, { serialize: false });
// Ensure the input element's parent exists
if (this.inputEl && this.inputEl.inputEl && this.inputEl.inputEl.parentNode) {
// Create overlay div for highlighting
this.overlayEl = document.createElement('div');
this.overlayEl.className = 'input-overlay';
this.inputEl.inputEl.parentNode.insertBefore(
this.overlayEl,
this.inputEl.inputEl
);
this.inputEl.inputEl.style.background = 'transparent';
addEventListeners(this);
addObversers(this);
setOverlayPosition(this.inputEl, this.overlayEl);
setOverlayStyle(this.inputEl, this.overlayEl);
} else {
console.error('Parent node of input element is not available.');
}
}; };
function addEventListeners(node) {
node.inputEl.inputEl.addEventListener('input', () => {
syncText(node.inputEl.inputEl, node.overlayEl);
setOverlayStyle(node.inputEl, node.overlayEl);
});
node.inputEl.inputEl.addEventListener('keydown', (event) => {
if (
event.ctrlKey &&
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
) {
setTimeout(() => {
syncText(node.inputEl.inputEl, node.overlayEl);
}, 10);
}
});
}
function addObversers(node) {
const observer = new MutationObserver(() => {
setOverlayPosition(node.inputEl, node.overlayEl);
});
observer.observe(node.inputEl.inputEl, {
attributes: true,
attributeFilter: ['style'],
childList: true,
subtree: true,
characterData: true,
});
const parentObserver = new MutationObserver(() => {
if (!document.contains(node.inputEl.inputEl)) {
node.overlayEl.remove();
}
});
parentObserver.observe(node.inputEl.inputEl.parentNode, {
childList: true,
});
}
async function setTextHighlightType(inputEl) {
const highlightGradient = await SettingUtils.getSetting('sn0w.TextboxGradientColors');
let shouldHighlightGradient = false;
switch(highlightGradient) {
case "nesting":
shouldHighlightGradient = false;
break;
case "strength":
shouldHighlightGradient = true;
break;
default:
shouldHighlightGradient = false;
break;
}
inputEl.highlightGradient = shouldHighlightGradient;
}
async function setTextColors(inputEl, overlayEl) {
const customTextboxColors = await SettingUtils.getSetting('sn0w.TextboxColors');
setTextHighlightType(inputEl);
const defaultColors = [
'#559c22',
'#229c57',
'#229c8b',
'#226f9c',
'#22479c',
];
const colors = (!customTextboxColors || customTextboxColors.trim() === '')
? defaultColors
: customTextboxColors.split('\n');
inputEl.colors = colors.map(color =>
color.charAt(0) === '#' ? SettingUtils.hexToRgb(color) : color
);
inputEl.errorColor = 'var(--error-text)';
syncText(inputEl, overlayEl);
return colors;
}
function escapeHtml(char) {
switch (char) {
case '<':
return '&lt;';
case '>':
return '&gt;';
default:
return char;
}
}
function interpolateColor(color1, color2, factor) {
const rgb1 = color1.match(/\d+/g).map(Number);
const rgb2 = color2.match(/\d+/g).map(Number);
const r = Math.round(rgb1[0] + factor * (rgb2[0] - rgb1[0]));
const g = Math.round(rgb1[1] + factor * (rgb2[1] - rgb1[1]));
const b = Math.round(rgb1[2] + factor * (rgb2[2] - rgb1[2]));
return `rgb(${r}, ${g}, ${b})`;
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function charToType(char) {
switch(char) {
case '<':
return '-lora';
case 'e':
return '-embedding';
default:
return '';
}
}
function extractLoraName(text, currentIndex) {
const loraPrefix = "lora:";
const startIndex = text.lastIndexOf(loraPrefix, currentIndex) + loraPrefix.length;
// Find the end of the LoRA name (next space, comma, or ".safetensors")
let endIndex = text.indexOf(' ', startIndex);
const commaIndex = text.indexOf(',', startIndex);
const safetensorsIndex = text.indexOf('.safetensors', startIndex);
if (endIndex === -1 || (commaIndex !== -1 && commaIndex < endIndex)) {
endIndex = commaIndex;
}
if (endIndex === -1 || (safetensorsIndex !== -1 && safetensorsIndex < endIndex)) {
endIndex = safetensorsIndex;
}
// If there's no space, comma, or ".safetensors" after the LoRA name, consider the end of the text
const loraName = endIndex === -1 ? text.slice(startIndex) : text.slice(startIndex, endIndex);
return loraName;
}
function validateName(validFiles, name) {
if (!validFiles) {
console.error('Valid names not defined or not an array.');
return false;
}
if (validFiles.includes(name)) {
return true;
} else {
return false;
}
}
function processTags(tags) {
let returnArray = [];
tags.forEach(tag => {
returnArray.push(processTag(tag));
})
return returnArray;
}
function processTag(tag) {
let trimmedTag = tag.trim();
// Remove HTML tags
trimmedTag = trimmedTag.replace(/<|>/g, "");
// Remove the first character if it is a parenthesis
if (trimmedTag.startsWith('(')) {
trimmedTag = trimmedTag.substring(1).trim();
}
// Remove everything after a colon
const colonIndex = trimmedTag.indexOf(':');
if (colonIndex !== -1) {
trimmedTag = trimmedTag.substring(0, colonIndex).trim();
}
return trimmedTag;
}
const charPairs = {
'(': ')',
'<': '>',
};
async function syncText(inputEl, overlayEl, tries = 1) {
const text = inputEl.value;
overlayEl.textContent = text;
const colors = inputEl.colors;
const errorColor = inputEl.errorColor;
const shouldHighlightGradient = inputEl.highlightGradient;
const loraColor = colors ? colors[0] : undefined;
if (!colors || !errorColor || !loraColor || shouldHighlightGradient == undefined) {
if (tries < 5) {
setTimeout(() => syncText(inputEl, overlayEl, tries++), tries * 5);
}
return;
}
let uniqueIdCounter = 0;
const generateUniqueId = (type = "") => `span-${uniqueIdCounter++}${type}`;
let nestingLevel = 0;
let highlightedText = '';
let lastIndex = 0;
let spanStack = [];
const uniqueIdMap = new Map();
const extractTags = (text) => {
let tags = text.split(',').map(tag => tag.trim());
tags = processTags(tags);
const tagCounts = new Map();
tags.forEach(tag => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
});
return { tags, tagCounts };
};
const { tags, tagCounts } = extractTags(text);
/*
* This loop iterates over each character in the `text` string to apply syntax highlighting and handle special cases.
* - lastIndex: index of the last processed character, used to slice text segments.
* - spanStack: stack to manage opened spans, storing their ids, start positions, original colors, and characters.
*/
for (let i = 0; i < text.length; i++) {
const char = text[i].toLowerCase();
// Handle escape characters
if (char === '\\' && i + 1 < text.length && (text[i + 1] === '(' || text[i + 1] === ')')) {
i++;
continue;
}
// Handle wrong escape characters
if (char === '/' && i + 1 < text.length && (text[i + 1] === '(' || text[i + 1] === ')')) {
highlightedText += text.slice(lastIndex, i) + `<span style="background-color: ${errorColor};">/</span>`;
console.error(`Replace "${char}" at char ${i} with "\\"`);
lastIndex = i + 1;
continue;
}
let color = colors[nestingLevel % colors.length];
let uniqueId = generateUniqueId(charToType(char));
if (inputEl.highlightGradient === true) {
color = `id-${uniqueId}`;
}
switch(char) {
case '(':
case '<':
highlightedText += text.slice(lastIndex, i) + `<span id="${uniqueId}" style="background-color: ${color};">${escapeHtml(char)}`;
spanStack.push({ id: uniqueId, start: highlightedText.length, originalSpan: `<span id="${uniqueId}" style="background-color: ${color};">`, nestingLevel, originalColor: color, originalChar: char });
nestingLevel++;
lastIndex = i + 1;
break;
case 'e':
let embeddingColor = colors[0];
const embeddingPrefix = "embedding:";
// Check if the text starts with "embedding:" at position i
if (text.toLowerCase().startsWith(embeddingPrefix, i)) {
// Find the end of the embedding (next space or comma)
let endIndex = i + embeddingPrefix.length;
while (endIndex < text.length && text[endIndex] !== ' ' && text[endIndex] !== ',') {
endIndex++;
}
// Get the full embedding text
const embeddingText = text.slice(i, endIndex);
if (validateName(inputEl.validEmbeddings, embeddingText.split(":")[1]) === false) {
embeddingColor = errorColor;
}
const wrappedEmbedding = `<span id="${uniqueId}" style="background-color: ${embeddingColor};">${escapeHtml(embeddingText)}</span>`;
highlightedText += text.slice(lastIndex, i) + wrappedEmbedding;
lastIndex = i + embeddingText.length;
}
break;
case ')':
case '>':
if (nestingLevel > 0) {
const { id, originalColor, originalChar } = spanStack[spanStack.length - 1];
if (charPairs[originalChar] === char) {
spanStack.pop();
highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}</span>`;
nestingLevel--;
// Extract and validate the LoRA name
if (id.endsWith('lora')) {
const loraName = extractLoraName(text, i);
if (validateName(inputEl.validLoras, loraName) === false) {
uniqueIdMap.set(id, [errorColor, originalColor]);
lastIndex = i + 1;
continue;
}
}
if (inputEl.highlightGradient === true || id.endsWith('lora')) {
// Check for the strength
const strengthText = text.slice(Math.max(0, i - 10), i);
const match = strengthText.match(/(\d+(\.\d+)?)\s*$/);
if (match) {
const strength = parseFloat(match[1]);
const clampedStrength = Math.max(0, Math.min(2, strength));
const normalizedStrength = clampedStrength / 2;
const newColor = interpolateColor(colors[0], colors[colors.length - 1], easeInOutCubic(normalizedStrength));
uniqueIdMap.set(id, [newColor, originalColor]);
} else {
uniqueIdMap.set(id, [interpolateColor(colors[0], colors[colors.length - 1], 0.5), originalColor]);
}
}
lastIndex = i + 1;
}
}
break;
}
}
highlightedText += text.slice(lastIndex);
if (nestingLevel > 0) {
// Apply red highlight to the unclosed spans
while (spanStack.length > 0) {
const spanData = spanStack.pop();
if (spanData) {
const { id, start, originalSpan } = spanData;
const errorSpanTag = `<span id="${id}" style="background-color: ${errorColor};">`;
if (originalSpan) {
highlightedText = highlightedText.replace(originalSpan, errorSpanTag);
highlightedText += `</span>`;
}
}
}
}
// Apply the updated colors to the highlighted text
uniqueIdMap.forEach((colors, id) => {
const [newColor, originalColor] = [colors[0], colors[1]];
highlightedText = highlightedText.replace(`id="${id}" style="background-color: ${originalColor};"`, `id="${id}" style="background-color: ${newColor};"`);
});
// Highlight duplicate tags
highlightedText.split(/(,)/).map(segment => {
if (segment === ',') {
return segment;
}
const trimmedSegment = processTag(segment);
// Check for duplicates
if (tagCounts.get(trimmedSegment) > 1) {
highlightedText = highlightedText.replaceAll(trimmedSegment, `<span style="background-color: ${errorColor};">${trimmedSegment}</span>`)
}
}).join('');
overlayEl.innerHTML = highlightedText;
}
function setOverlayPosition(inputEl, overlayEl) {
const textareaStyle = window.getComputedStyle(inputEl.inputEl);
overlayEl.style.left = textareaStyle.left;
overlayEl.style.top = textareaStyle.top;
overlayEl.style.width = textareaStyle.width;
overlayEl.style.height = textareaStyle.height;
overlayEl.style.display = textareaStyle.display;
overlayEl.style.transform = textareaStyle.transform;
overlayEl.style.transformOrigin = textareaStyle.transformOrigin;
}
function setOverlayStyle(inputEl, overlayEl) {
const textareaStyle = window.getComputedStyle(inputEl.inputEl);
overlayEl.style.backgroundColor = 'var(--comfy-input-bg)';
overlayEl.style.position = 'absolute';
overlayEl.style.fontFamily = textareaStyle.fontFamily;
overlayEl.style.fontSize = textareaStyle.fontSize;
overlayEl.style.fontWeight = textareaStyle.fontWeight;
overlayEl.style.lineHeight = textareaStyle.lineHeight;
overlayEl.style.letterSpacing = textareaStyle.letterSpacing;
overlayEl.style.whiteSpace = textareaStyle.whiteSpace;
overlayEl.style.color = 'rgba(0,0,0,0)';
overlayEl.style.padding = textareaStyle.padding;
overlayEl.style.boxSizing = textareaStyle.boxSizing;
overlayEl.style.zIndex = '1';
overlayEl.style.pointerEvents = 'none';
overlayEl.style.color = 'transparent';
overlayEl.style.overflow = 'hidden';
overlayEl.style.whiteSpace = 'pre-wrap';
overlayEl.style.wordWrap = 'break-word';
}
async function setValidFiles(inputEl) {
inputEl.validLoras = await getValidFiles("loras");
inputEl.validEmbeddings = await getValidFiles("embeddings");
}
async function getValidFiles(type) {
return await SettingUtils.fetchApi(`${SettingUtils.API_PREFIX}/${type}`, {
method: "GET",
});
}
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
this.populate(); this.populate();
setTextColors(this.inputEl.inputEl, this.overlayEl);
setValidFiles(this.inputEl.inputEl);
this.getTextboxText = this.getTextboxText.bind(this); this.getTextboxText = this.getTextboxText.bind(this);
}; };
} }
}, },
}); });
const settingsDefinitions = [
{
id: 'sn0w.TextboxColors',
name: '[Sn0w] Custom Textbox Colors',
type: SettingUtils.createMultilineSetting,
defaultValue: '#559c22\n#229c57\n#229c8b\n#226f9c\n#22479c',
attrs: { tooltip: 'A list of either rgb or hex colors, one color per line.' },
onChange: () => {
const nodes = app.graph._nodes.filter(node => node.type === 'Copy/Paste Textbox');
nodes.forEach(node => {
node.updateHighlightType();
});
},
},
{
id: 'sn0w.TextboxGradientColors',
name: '[Sn0w] Textbox Highlight Type',
options: [
{ text: 'Nesting', value: 'nesting' },
{ text: 'Strength', value: 'strength' },
],
type: 'combo',
defaultValue: 'nesting',
onChange: () => {
const nodes = app.graph._nodes.filter(node => node.type === 'Copy/Paste Textbox');
nodes.forEach(node => {
node.updateHighlightType();
});
},
}
];
const registerSetting = (settingDefinition) => {
const extension = {
name: settingDefinition.id,
init() {
const setting = app.ui.settings.addSetting({
id: settingDefinition.id,
name: settingDefinition.name,
options: settingDefinition.options,
type: settingDefinition.type,
defaultValue: settingDefinition.defaultValue,
tooltip: settingDefinition.tooltip,
attrs: settingDefinition.attrs,
onChange: settingDefinition.onChange,
});
},
};
app.registerExtension(extension);
};
// Register settings
settingsDefinitions.forEach((setting) => {
registerSetting(setting);
});
-372
View File
@@ -1,372 +0,0 @@
[
{
"name": "Darkness (Konosuba)",
"associated_string": "darkness \\(konosuba\\), kono subarashii sekai ni shukufuku wo!",
"prompt": "blonde hair, blue eyes, x hair ornament, large breasts, hair between eyes, hair ornament, long hair"
},
{
"name": "Ghislaine Dedoldia (Mushoku Tensei)",
"associated_string": "ghislaine dedoldia, mushoku tensei",
"prompt": "animal ear fluff, animal ears, eyepatch, grey hair, large breasts, long hair, red eyes, dark-skinned female, dark skin"
},
{
"name": "Uzaki Hana (Uzaki-chan)",
"associated_string": "uzaki hana, uzaki-chan wa asobitai!",
"prompt": "fang, grey hair, looking at viewer, short hair, blue eyes, breasts, large breasts"
},
{
"name": "Gotoh Hitori (Bocchi the Rock!)",
"associated_string": "gotoh hitori, bocchi the rock!",
"prompt": "pink hair, blue eyes, breasts, cube hair ornament, hair between eyes, hair ornament, large breasts, long hair"
},
{
"name": "Aqua (Konosuba)",
"associated_string": "aqua \\(konosuba\\), kono subarashii sekai ni shukufuku wo!",
"prompt": "blue eyes, blue hair, hair ornament, long hair, large breasts"
},
{
"name": "Nami (One Piece)",
"associated_string": "nami \\(one piece\\), one piece",
"prompt": "orange hair, orange eyes, large breasts, shoulder tattoo, long hair"
},
{
"name": "Power (Chainsaw Man)",
"associated_string": "power \\(chainsaw man\\), chainsaw man",
"prompt": "blonde hair, cross-shaped pupils, demon girl, demon horns, double-parted bangs, hair between eyes, horns, long hair, red horns, symbol-shaped pupils, yellow eyes"
},
{
"name": "Mirko (MHA)",
"associated_string": "mirko, boku no hero academia",
"prompt": "animal ears, breasts, dark-skinned female, large breasts, long hair, parted bangs, rabbit ears, rabbit girl, red eyes, toned, white hair"
},
{
"name": "Uraraka Ochako (MHA)",
"associated_string": "uraraka ochako, boku no hero academia",
"prompt": "brown eyes, brown hair, medium breasts, short hair"
},
{
"name": "Ashido Mina (MHA)",
"associated_string": "ashido mina, boku no hero academia",
"prompt": "black sclera, colored skin, large breasts, pink hair, pink skin, yellow eyes"
},
{
"name": "Bakugou Mitsuki (MHA)",
"associated_string": "bakugou mitsuki, boku no hero academia",
"prompt": "blonde hair, brown eyes, large breasts, medium hair, spiked hair"
},
{
"name": "Yaoyorozu Momo (MHA)",
"associated_string": "yaoyorozu momo, boku no hero academia",
"prompt": "black hair, large breasts, long hair, ponytail"
},
{
"name": "Toga Himiko (MHA)",
"associated_string": "toga himiko, boku no hero academia",
"prompt": "blonde hair, blunt bangs, double bun, fang, hair bun, medium breasts, messy hair, yellow eyes"
},
{
"name": "Makima (Chainsaw Man)",
"associated_string": "makima \\(chainsaw man\\), chainsaw man",
"prompt": "breasts, hair between eyes, long hair, medium breasts, red hair, ringed eyes, yellow eyes"
},
{
"name": "Kitagawa Marin (Dress Up)",
"associated_string": "kitagawa marin, sono bisque doll wa koi wo suru",
"prompt": "blonde hair, choker, ear piercing, bangs, jewelry, large breasts, earrings, very long hair, red eyes, swept bangs"
},
{
"name": "Lucy (Cyberpunk)",
"associated_string": "lucy \\(cyberpunk\\), cyberpunk \\(series\\), cyberpunk edgerunners",
"prompt": "breasts, eyeliner, makeup, medium breasts, multicolored eyes, multicolored hair, parted bangs, red eyeliner, short hair, white hair"
},
{
"name": "Rebecca (Cyberpunk)",
"associated_string": "rebecca \\(cyberpunk\\), cyberpunk \\(series\\), cyberpunk edgerunners",
"prompt": "artificial eye, breasts, colored sclera, colored skin, green eyes, green hair, leg tattoo, long hair, mechanical eye, medium breasts, neck tattoo, parted bangs, red sclera, stomach tattoo, tattoo, white skin"
},
{
"name": "Fern (Frieren)",
"associated_string": "fern \\(sousou no frieren\\), sousou no frieren",
"prompt": "long hair, large breasts, blunt bangs, purple eyes, purple hair, sidelocks, straight hair"
},
{
"name": "Frieren (Frieren)",
"associated_string": "frieren, sousou no frieren",
"prompt": "green eyes, grey hair, long hair, twintails, elf, pointy ears, parted bangs, small breasts"
},
{
"name": "Fujiwara Chika (Kaguya-sama)",
"associated_string": "fujiwara chika, kaguya-sama wa kokurasetai ~tensai-tachi no renai zunousen~",
"prompt": "black bow, blue eyes, blunt bangs, bow, breasts, hair bow, large breasts, long hair, pink hair"
},
{
"name": "Hestia (Danmachi)",
"associated_string": "hestia \\(danmachi\\), dungeon ni deai wo motomeru no wa machigatteiru darou ka",
"prompt": "black hair, blue eyes, large breasts, ribbon, twintails"
},
{
"name": "Ceres Fauna (Hololive)",
"associated_string": "ceres fauna, hololive, hololive english",
"prompt": "antlers, branch, green hair, hair over one eye, horns, large breasts, leaf, long hair, mole, mole under eye, tree horns, virtual youtuber, yellow eyes"
},
{
"name": "Shihouin Yoruichi (Bleach)",
"associated_string": "shihouin yoruichi, bleach",
"prompt": "dark-skinned female, large breasts, long hair, parted bangs, ponytail, purple hair, yellow eyes"
},
{
"name": "Eris Greyrat (Mushoku Tensei)",
"associated_string": "eris greyrat, mushoku tensei",
"prompt": "ahoge, hair between eyes, large breasts, long hair, red eyes, red hair, aged up"
},
{
"name": "Karin (Blue Archive)",
"associated_string": "karin \\(blue archive\\), blue archive",
"prompt": "black hair, dark-skinned female, hair between eyes, halo, large breasts, long hair, purple hair, purple halo, very long hair, yellow eyes"
},
{
"name": "Yae Miko (Genshin Impact)",
"associated_string": "yae miko, genshin impact",
"prompt": "animal ears, floppy ears, fox ears, hair between eyes, large breasts, long hair, pink hair, purple eyes, very long hair"
},
{
"name": "Lisa (Genshin Impact)",
"associated_string": "lisa \\(genshin impact\\), genshin impact",
"prompt": "light brown hair, green eyes, hair between eyes, hair ornament, large breasts"
},
{
"name": "Mona (Genshin Impact)",
"associated_string": "mona \\(genshin impact\\), genshin impact",
"prompt": "aqua eyes, black ribbon, hair ribbon, long hair, purple hair, ribbon, twintails"
},
{
"name": "Kujou Sara (Genshin Impact)",
"associated_string": "kujou sara, genshin impact",
"prompt": "black hair, large breasts, short hair, swept bangs, yellow eyes, bird mask, mask on head"
},
{
"name": "Noelle (Genshin Impact)",
"associated_string": "noelle \\(genshin impact\\), genshin impact",
"prompt": "blunt bangs, braid, braided bangs, brown eyes, grey hair, hair ornament, short hair, red rose"
},
{
"name": "Yor Briar (Spy x Family)",
"associated_string": "yor briar, spy x family",
"prompt": "black hair, large breasts, long hair, red eyes, very long hair, white hairband"
},
{
"name": "Shylily (Vtuber)",
"associated_string": "shylily, shylily \\(bikini\\), indie virtual youtuber",
"prompt": "ahoge, blue hair, large breasts, long hair, multicolored hair, purple eyes, two-tone hair, virtual youtuber, white hair, animal ears, facial mark, orca girl"
},
{
"name": "Akuma Nihmune (Vtuber)",
"associated_string": "akuma nihmune, indie virtual youtuber",
"prompt": "purple hair, demon horns, mole under eye, heart tattoo, ear piercing, earrings"
},
{
"name": "Hasumi (Blue Archive)",
"associated_string": "hasumi \\(blue archive\\), blue archive",
"prompt": "black hair, large breasts, long hair, ponytail, red eyes, very long hair, feathered wings, red halo"
},
{
"name": "Yuuki Asuna (SAO)",
"associated_string": "asuna \\(sao\\), sword art online",
"prompt": "braid, brown eyes, brown hair, french braid, large breasts, long hair, short ponytail, very long hair"
},
{
"name": "Kafka (Honkai)",
"associated_string": "kafka \\(honkai: star rail\\), honkai \\(series\\), honkai: star rail",
"prompt": "colored inner hair, hair between eyes, large breasts, multicolored hair, pink hair, purple eyes, purple hair"
},
{
"name": "Albedo (Overlord)",
"associated_string": "albedo \\(overlord\\), overlord \\(maruyama\\)",
"prompt": "ahoge, black hair, hair between eyes, large breasts, long hair, very long hair, yellow eyes, slit pupils, horns, feathered wings, low wings"
},
{
"name": "Asuka (Evangelion)",
"associated_string": "souryuu asuka langley, neon genesis evangelion",
"prompt": "aged up, blue eyes, brown hair, hair between eyes, long hair, medium breasts"
},
{
"name": "Purah (Zelda)",
"associated_string": "purah, the legend of zelda, the legend of zelda: tears of the kingdom",
"prompt": "eyeshadow, hair ornament, large breasts, makeup, red eyes, red eyeshadow, short hair, white hair"
},
{
"name": "Owari (Azur Lane)",
"associated_string": "owari \\(azur lane\\), owari \\(shimmering forsythia\\) (azur lane), azur lane",
"prompt": "asymmetrical bangs, blonde hair, braid, brown eyes, dark-skinned female, dark skin, large breasts, long bangs, long hair, low twin braids, twin braids, very long hair"
},
{
"name": "Zero Two (Darling)",
"associated_string": "zero two \\(darling in the franxx\\), darling in the franxx",
"prompt": "blunt bangs, green eyes, medium breasts, long hair, pink hair, very long hair, white hairband, red horns"
},
{
"name": "Implacable (Azur Lane)",
"associated_string": "implacable \\(azur lane\\), azur lane",
"prompt": "blonde hair, hair between eyes, large breasts, long hair, red eyes, very long hair, white horns, fake horns"
},
{
"name": "Hanekawa Tsubasa (Monogatari)",
"associated_string": "hanekawa tsubasa, monogatari \\(series\\), bakemonogatari",
"prompt": "braid, braided ponytail, brown hair, large breasts, long hair, purple eyes, single braid"
},
{
"name": "Iori Utahime (Jujutsu Kaisen)",
"associated_string": "iori utahime, jujutsu kaisen",
"prompt": "black hair, brown eyes, large breasts, long hair, scar on face, swept bangs"
},
{
"name": "Tsunade (Naruto)",
"associated_string": "tsunade \\(naruto\\), naruto \\(series\\), naruto",
"prompt": "blonde hair, forehead mark, large breasts, long hair, parted bangs, brown eyes, mature female, hair intakes"
},
{
"name": "Ichinose Asuna (Blue Archive)",
"associated_string": "asuna \\(blue archive\\), asuna \\(bunny\\) \\(blue archive\\), blue archive",
"prompt": "blue eyes, blue ribbon, hair ribbon, large breasts, light brown hair, very long hair, ribbon"
},
{
"name": "Ouro Kronii (Hololive)",
"associated_string": "ouro kronii, hololive, hololive english",
"prompt": "blue eyes, blue ribbon, hair ribbon, large breasts, blue hair, very long hair, ribbon, chain headband"
},
{
"name": "Nerissa Ravencroft (Hololive)",
"associated_string": "nerissa ravencroft, hololive, hololive english",
"prompt": "black hair, blue hair, large breasts, colored inner hair, hair ornament, long hair, multicolored hair, red eyes, two-tone hair, very long hair, virtual youtuber, asymmetrical horns, horns, demon horns, mole, mole under eye"
},
{
"name": "Mori Calliope (Hololive)",
"associated_string": "mori calliope, hololive, hololive english",
"prompt": "blunt bangs, large breasts, long hair, pink eyes, pink hair, virtual youtuber"
},
{
"name": "Oozora Subaru (Hololive)",
"associated_string": "oozora subaru, hololive",
"prompt": "aqua eyes, brown hair, medium breasts, selfie, short hair, virtual youtuber"
},
{
"name": "Houshou Marine (Hololive)",
"associated_string": "houshou marine, hololive",
"prompt": "hair ribbon, heterochromia, large breasts, red eyes, red hair, red ribbon, ribbon, twintails, virtual youtuber, yellow eyes"
},
{
"name": "Hakos Baelz (Hololive)",
"associated_string": "hakos baelz, hololive, hololive english",
"prompt": "black hair, blue eyes, hair between eyes, medium breasts, long hair, multicolored hair, red hair, twintails, uneven twintails, virtual youtuber, white hair"
},
{
"name": "Shishiro Botan (Hololive)",
"associated_string": "shishiro botan, hololive",
"prompt": "grey eyes, grey hair, hair between eyes, large breasts, long hair, virtual youtuber, animal ear piercing, animal ears, lion ears, lion girl"
},
{
"name": "Takane Lui (Hololive)",
"associated_string": "takane lui, hololive",
"prompt": "hair between eyes, large breasts, pink hair, virtual youtuber, thigh gap, head wings, hair wings"
},
{
"name": "Shirakami Fubuki (Hololive)",
"associated_string": "shirakami fubuki, hololive",
"prompt": "ahoge, animal ears, aqua eyes, braid, hair between eyes, large breasts, side braid, virtual youtuber, white hair, fox girl, fox ears"
},
{
"name": "Nekomata Okayu (Hololive)",
"associated_string": "nekomata okayu, hololive",
"prompt": "ahoge, animal ears, crossed bangs, large breasts, medium hair, pink hair, purple eyes, virtual youtuber"
},
{
"name": "Irys (Hololive)",
"associated_string": "irys \\(hololive\\), hololive, hololive english",
"prompt": "blue eyes, heterochromia, horns, medium breasts, long hair, pointy ears, purple eyes, red hair, very long hair, virtual youtuber"
},
{
"name": "Ninomae Ina'nis (Hololive)",
"associated_string": "ninomae ina'nis, hololive, hololive english",
"prompt": "blue eyes, blunt bangs, long hair, mole, mole under eye, multicolored hair, orange hair, pointy ears, purple hair, small breasts, very long hair, virtual youtuber"
},
{
"name": "Shiori Novella (Hololive)",
"associated_string": "shiori novella, hololive, hololive english",
"prompt": "black eyeliner, black hair, eyeliner, grey hair, hair ornament, large breasts, long hair, makeup, multicolored hair, two-tone hair, virtual youtuber, white hair, yellow eyes"
},
{
"name": "Inoue Orihime (Bleach)",
"associated_string": "inoue orihime, bleach",
"prompt": "brown eyes, hair ornament, large breasts, long hair, orange hair, parted bangs"
},
{
"name": "Aizawa Tomo (Tomo-Chan)",
"associated_string": "aizawa tomo, tomo-chan wa onna no ko",
"prompt": "fang, hair between eyes, large breasts, long bangs, red eyes, red hair, short hair, tomboy"
},
{
"name": "Inuyama Aoi (Yurucamp)",
"associated_string": "inuyama aoi, yurucamp",
"prompt": "asymmetrical hair, brown hair, green eyes, hair ornament, large breasts, long hair, side ponytail, skin fang"
},
{
"name": "Sarashina Ruka (Rent a Girlfriend)",
"associated_string": "sarashina ruka, kanojo okarishimasu",
"prompt": "black hair, green eyes, hair ribbon, large breasts, ribbon, short hair"
},
{
"name": "Mizuhara Chizuru (Rent a Girlfriend)",
"associated_string": "mizuhara chizuru, kanojo okarishimasu",
"prompt": "brown eyes, brown hair, large breasts, long hair, braid"
},
{
"name": "Sakurasawa Sumi (Rent a Girlfriend)",
"associated_string": "sakurasawa sumi, kanojo okarishimasu",
"prompt": "braid, hair ribbon, large breasts, red eyes, red hair, ribbon, short hair, single braid, white ribbon"
},
{
"name": "Baiken (Guilty Gear)",
"associated_string": "baiken, guilty gear",
"prompt": "big hair, large breasts, long hair, pink hair, ponytail, red eyes, scar, scar across eye, scar on face, amputee"
},
{
"name": "Alicia (Granblue Fantasy)",
"associated_string": "alicia \\(granblue fantasy\\), granblue fantasy",
"prompt": "blunt bangs, demon horns, horns, large breasts, long hair, pointy ears, red eyes, very long hair, draph"
},
{
"name": "Narmaya (Granblue Fantasy)",
"associated_string": "narmaya \\(granblue fantasy\\), granblue fantasy",
"prompt": "blue eyes, braid, hair ornament, horns, large breasts, long hair, pointy ears, draph"
},
{
"name": "Izmir (Granblue Fantasy)",
"associated_string": "izmir, granblue fantasy",
"prompt": "black horns, brown eyes, hair between eyes, horns, large breasts, long hair, mole, mole under mouth, pointy ears, very long hair, grey hair, draph"
},
{
"name": "Anila (Granblue Fantasy)",
"associated_string": "anila \\(granblue fantasy\\), granblue fantasy",
"prompt": "blonde hair, blunt bangs, brown eyes, curved horns, horns, large breasts, long hair, draph"
},
{
"name": "Nagatoro Hayase (Nagatoro-san)",
"associated_string": "nagatoro hayase, ijiranaide nagatoro-san",
"prompt": "asymmetrical bangs, black hair, brown eyes, dark-skinned female, fang, hair ornament, small breasts, long hair, tan, tanline"
},
{
"name": "Tatsumaki (OPM)",
"associated_string": "tatsumaki, one-punch man",
"prompt": "green eyes, green hair, messy hair, short hair, small breasts, swept bangs"
},
{
"name": "Fubuki (OPM)",
"associated_string": "fubuki \\(one-punch man\\), one-punch man",
"prompt": "green eyes, dark green hair, hair between eyes, large breasts, short hair"
},
{
"name": "Nicole Demara (ZZZ)",
"associated_string": "nicole demara, zenless zone zero",
"prompt": "green eyes, hair ribbon, large breasts, long hair, pink hair, ribbon, two side up"
}
]