Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5a1f36e6 | ||
|
|
4567bbfe79 | ||
|
|
dd091e2ab6 | ||
|
|
805d7a20ac | ||
|
|
c1270c4b61 | ||
|
|
6531c579bb | ||
|
|
80d4c35cac | ||
|
|
33afff0d33 | ||
|
|
241c4ba463 | ||
|
|
ca9e5364d3 | ||
|
|
e81a8e2403 | ||
|
|
a1a15f3e45 | ||
|
|
e655b86c96 | ||
|
|
f222886ff2 | ||
|
|
934a07ee92 | ||
|
|
210caabfba | ||
|
|
d260d1e0cf | ||
|
|
f8a9fd3472 | ||
|
|
fb21b3b424 | ||
|
|
6fd3d423a5 | ||
|
|
186b64e949 | ||
|
|
db1c7a10fd | ||
|
|
4cc9df46fd | ||
|
|
c828b0a29e | ||
|
|
ea1b9aa344 | ||
|
|
fa43dbe8a7 | ||
|
|
4b9aee6992 | ||
|
|
6fd884fdc5 | ||
|
|
474f343a20 | ||
|
|
39eec6877f | ||
|
|
b809c5b739 | ||
|
|
7c5113bb7d | ||
|
|
77409a85f6 | ||
|
|
c0622e25ac | ||
|
|
e1c8a5f728 | ||
|
|
b2f349b2d0 | ||
|
|
eb2398ec18 | ||
|
|
f029d63435 | ||
|
|
57ebb73dad | ||
|
|
bd91ace311 | ||
|
|
23dcfa8002 | ||
|
|
8b24769ae1 | ||
|
|
8e9324de22 | ||
|
|
cefaf2bd6a | ||
|
|
de099e2ad9 | ||
|
|
18408d6d94 | ||
|
|
e391de23f2 | ||
|
|
edc0fc8af2 | ||
|
|
d17bced11d | ||
|
|
d1160f2ac5 | ||
|
|
3b18403d86 | ||
|
|
3ec8fa68c1 | ||
|
|
44ee36e996 | ||
|
|
e6eac9f997 | ||
|
|
7aa2efd684 | ||
|
|
98ceee382e | ||
|
|
af8f95bde8 | ||
|
|
59db9ed1af | ||
|
|
281edcfbef | ||
|
|
f792731fe8 | ||
|
|
7605c78a52 | ||
|
|
0cc76218fe | ||
|
|
256a6dcfb0 | ||
|
|
49e071f551 | ||
|
|
5549adcf51 | ||
|
|
719aec3098 | ||
|
|
a5a03d2a4b | ||
|
|
a7b538e773 | ||
|
|
4e0cd8b992 | ||
|
|
a2aeb110cb | ||
|
|
4fe93f0c44 | ||
|
|
a51ddb6c57 | ||
|
|
b8da34e35e | ||
|
|
f227997bb3 | ||
|
|
d133077e69 | ||
|
|
b9e725d12d | ||
|
|
81a03f8b99 | ||
|
|
103d3b0277 | ||
|
|
2bfbfc63ef | ||
|
|
1a4b0104f4 | ||
|
|
da1ded76af |
+14
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"printWidth": 999
|
||||||
|
}
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 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 |
|
||||||
:-------------------------:|:-------------------------:
|
| :----------------------------------------: | :-------------------------------------: |
|
||||||
 | 
|
|  |  |
|
||||||
|
|
||||||
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)
|
||||||
>```
|
> ```
|
||||||
> 
|
>
|
||||||
> ```
|
> 
|
||||||
>(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)
|
>
|
||||||
>```
|
> ```
|
||||||
> 
|
> (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
|
>
|
||||||
>```
|
> 
|
||||||
> 
|
>
|
||||||
> ```
|
> ```
|
||||||
>(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
|
||||||
>```
|
> ```
|
||||||
> 
|
>
|
||||||
|
> 
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> (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
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 
|
||||||
|
>
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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>
|
||||||
>
|
>
|
||||||
> 
|
> 
|
||||||
|
>
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
|
>
|
||||||
|
> </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.
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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.
|
||||||
|
>
|
||||||
|
> 
|
||||||
>
|
>
|
||||||
> 
|
|
||||||
> </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>
|
||||||
>
|
>
|
||||||
> 
|
> 
|
||||||
|
>
|
||||||
> </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>
|
||||||
>
|
>
|
||||||
> 
|
> 
|
||||||
|
>
|
||||||
> </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>
|
||||||
>
|
>
|
||||||
> 
|
> 
|
||||||
|
>
|
||||||
> </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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Known Issues
|
# Known Issues
|
||||||
|
|
||||||
* Embedding highlighting does not work on linux.
|
- Embedding highlighting does not work on linux.
|
||||||
|
|||||||
+120
-11
@@ -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
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
onnxruntime
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).")
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 '<';
|
|
||||||
case '>':
|
|
||||||
return '>';
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user