diff --git a/.github/worflows/character_text.yml b/.github/worflows/character_text.yml
new file mode 100644
index 0000000..6c98c2c
--- /dev/null
+++ b/.github/worflows/character_text.yml
@@ -0,0 +1,28 @@
+name: Run Character Tag Validator
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: 3.10
+
+ - name: Install dependencies
+ run: |
+ pip install requests
+
+ - name: Run script
+ id: run_script
+ run: |
+ python testing/git_check_characters.py || echo "Script failed due to low-use characters. Please run the script locally to address this issue."
+
+ - name: Fail the build if the script failed
+ if: failure()
+ run: exit 1
diff --git a/README.md b/README.md
index d9d189c..14bd93f 100644
--- a/README.md
+++ b/README.md
@@ -38,8 +38,8 @@ In the ComfyUI settings, you can modify various settings. Some changes will appl
| Sort Characters By | Alphabetically
Series | Sorts characters either alphabetically or by their series. |
| Sort Loras By | Alphabetically
Last Changed | Sorts loras either alphabetically or by latest changed. |
-## Important Note
-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.
+> [!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.
```yaml
loras:
diff --git a/sn0w.py b/sn0w.py
index 7663c53..e4c43f4 100644
--- a/sn0w.py
+++ b/sn0w.py
@@ -22,6 +22,13 @@ import folder_paths
class ConfigReader:
"""
Handles reading and managing configuration settings for ComfyUI without using the API.
+
+ Methods:
+ get_setting(setting_id, default)
+ Retrieve a setting value from the configuration file.
+
+ is_comfy_portable()
+ Check if comfy is running in portable mode.
"""
DEFAULT_PATH = os.path.abspath(os.path.join(os.getcwd(), "user/default/comfy.settings.json"))
@@ -98,6 +105,13 @@ class Logger:
- WARNING
- INFORMATIONAL
- DEBUG
+
+ Methods:
+ log(message, level)
+ Log a message with a specified severity level.
+
+ print_sigmas_differences(name, sigmas)
+ Prints sigmas to terminal.
"""
PURPLE_TEXT = "\033[0;35m"
@@ -116,7 +130,7 @@ class Logger:
cls.enabled_levels = ConfigReader.get_setting("sn0w.LoggingLevel", ["INFORMATIONAL", "WARNING"])
@classmethod
- def print_sn0w(cls, message, color):
+ def _print_sn0w(cls, message, color):
"""Print a message with a specific color prefix."""
print(f"{color}{cls.PREFIX}{cls.RESET_TEXT}{message}")
@@ -132,7 +146,7 @@ class Logger:
# Check if the message's level is in the enabled log levels
if level.upper() in self.enabled_levels or level.upper() in self.ALWAYS_LOG:
- self.print_sn0w(message, color)
+ self._print_sn0w(message, color)
def print_sigmas_differences(self, name, sigmas):
"""
@@ -143,7 +157,7 @@ class Logger:
sigmas (torch.Tensor): A 1D tensor of sigmas with a zero appended at the end.
"""
if "DEBUG" in self.enabled_levels:
- self.print_sn0w(f"Scheduler: {name}", self.PURPLE_TEXT)
+ self._print_sn0w(f"Scheduler: {name}", self.PURPLE_TEXT)
print("Index | Sigma Value | Difference to Next | % Difference")
print("-" * 65)
@@ -165,6 +179,28 @@ class Logger:
class Utility:
"""
Utility class providing various static methods for common operations.
+
+ Methods:
+ levenshtein_distance(s1, s2):
+ Calculate the Levenshtein distance between two strings.
+
+ image_batch(**kwargs):
+ Concatenate and batch multiple image tensors.
+
+ get_model_type(model_patcher):
+ Retrieve the type of the model from the model patcher.
+
+ get_model_type_simple(model_patcher):
+ Retrieve a standardized model type from the model patcher.
+
+ put_favourite_on_top(setting, arr):
+ Prioritize favorite items in a list based on a setting.
+
+ create_setting_entry(setting_type, setting_value):
+ Create a setting entry based on the type and value.
+
+ get_node_output(data, node_id, output_id):
+ Get the output of a node from the workflow data.
"""
logger = Logger()
@@ -235,7 +271,7 @@ class Utility:
if isinstance(arr, dict):
arr = list(arr.keys())
- favourites = ConfigReader.get_setting(setting, [])
+ favourites = ConfigReader.get_setting(setting, None)
if favourites is None:
return arr
diff --git a/testing/git_check_characters.py b/testing/git_check_characters.py
new file mode 100644
index 0000000..5937672
--- /dev/null
+++ b/testing/git_check_characters.py
@@ -0,0 +1,125 @@
+import xml.etree.ElementTree as ET
+from difflib import get_close_matches
+import csv
+import json
+import os
+import time
+import requests
+import sys
+
+# Constants
+CHARACTER_FILE_PATH = os.getenv("CHARACTER_FILE_PATH", "web/settings/characters.json")
+TAGS_FILE_PATH = os.getenv("TAGS_FILE_PATH", "testing/danbooru-tags.csv")
+BASE_URL = "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&name="
+REQUEST_DELAY = 0.5 # Delay between requests in seconds
+MIN_USES = int(os.getenv("MIN_USES", 100))
+SIMILARITY_THRESHOLD = 0.9
+
+RED_TEXT = "\033[0;31m"
+GREEN_TEXT = "\033[0;32m"
+YELLOW_TEXT = "\033[0;33m"
+RESET_TEXT = "\033[0m"
+
+
+def validate_character(character):
+ required_fields = ["name", "associated_string", "prompt"]
+ return all(field in character and character[field] is not None for field in required_fields)
+
+
+def load_tags(file_path):
+ tags = {}
+ with open(file_path, "r", encoding="utf-8") as file:
+ reader = csv.reader(file)
+ for row in reader:
+ tags[row[0]] = int(row[1])
+ return tags
+
+
+def normalize_tag(tag):
+ return tag.replace(" ", "_").replace("\\", "").strip()
+
+
+def check_tags_exist(tags, tag_list):
+ missing_tags = [normalize_tag(tag) for tag in tag_list if normalize_tag(tag) not in tags]
+ return missing_tags
+
+
+def suggest_close_tags(tags, missing_tags, n=3, threshold=0.8):
+ tag_keys = list(tags.keys())
+ suggestions = {}
+ for tag in missing_tags:
+ normalized_tag = normalize_tag(tag)
+ close_matches = get_close_matches(normalized_tag, tag_keys, n=n, cutoff=threshold)
+ if close_matches:
+ suggestions[tag] = close_matches
+ return suggestions
+
+
+with open(CHARACTER_FILE_PATH, "r", encoding="utf-8") as file:
+ characters_data = json.load(file)
+
+tags_data = load_tags(TAGS_FILE_PATH)
+
+successful_tags = set()
+low_use_characters = []
+total_valid_characters = 0
+good_characters_count = 0
+
+print(
+ f"{GREEN_TEXT}Note:{RESET_TEXT} Warnings for tags do not mean they should be removed, it is just there to make sure there are no misspelt tags"
+)
+
+for character in characters_data:
+ if validate_character(character):
+ total_valid_characters += 1
+ associated_tags = [tag.strip() for tag in character["associated_string"].split(",")]
+ prompt_tags = [tag.strip() for tag in character["prompt"].split(",")]
+
+ missing_tags = check_tags_exist(tags_data, prompt_tags)
+ if missing_tags:
+ print(
+ f"{YELLOW_TEXT}Warning: The following tags for character '{character['name']}' do not exist in TAGS_FILE_PATH: {', '.join(missing_tags)}{RESET_TEXT}"
+ )
+ suggestions = suggest_close_tags(tags_data, missing_tags, threshold=SIMILARITY_THRESHOLD)
+ for tag, matches in suggestions.items():
+ print(f"Suggestions for '{tag}': {', '.join(matches)}")
+
+ normalized_tag = normalize_tag(associated_tags[0])
+
+ if normalized_tag in successful_tags:
+ good_characters_count += 1
+ continue
+
+ url = f"{BASE_URL}{normalized_tag}"
+ response = requests.get(url, timeout=10)
+ time.sleep(REQUEST_DELAY)
+
+ if response.status_code == 200:
+ root = ET.fromstring(response.content)
+ if root is not None:
+ try:
+ tag_count = int(root.find("tag").find("count").text)
+ if tag_count < MIN_USES:
+ print(f"{RED_TEXT}{normalized_tag} DOES NOT HAVE ENOUGH USES: {tag_count}{RESET_TEXT}")
+ low_use_characters.append(character)
+ else:
+ print(f"{GREEN_TEXT}{normalized_tag} is good: {tag_count}{RESET_TEXT}")
+ good_characters_count += 1
+ except AttributeError:
+ print(f"Could not find count for {normalized_tag}")
+ else:
+ print(f"No root element found for {normalized_tag}")
+ else:
+ print(f"Failed to retrieve data for {normalized_tag}: {response.status_code}")
+ else:
+ print(f"Character not formatted correctly: {character}")
+
+if total_valid_characters > 0:
+ good_percentage = (good_characters_count / total_valid_characters) * 100
+ print(f"{good_percentage:.2f}% of characters are valid.")
+else:
+ print("No valid characters found.")
+
+if low_use_characters:
+ print("Script failed due to low-use characters. Please run the script locally to address this issue.")
+ sys.exit(1) # Exit with a non-zero status code to indicate failure
diff --git a/web/js/textbox.js b/web/js/textbox.js
index 40e7e62..d556174 100644
--- a/web/js/textbox.js
+++ b/web/js/textbox.js
@@ -103,12 +103,21 @@ app.registerExtension({
async function setTextHighlightType(inputEl) {
const highlightGradient = await SettingUtils.getSetting('sn0w.TextboxGradientColors');
- if (highlightGradient === null) {
- inputEl.highlightGradient = false;
- return;
+
+ let shouldHighlightGradient = false;
+ switch(highlightGradient) {
+ case "nesting":
+ shouldHighlightGradient = false;
+ break;
+ case "strength":
+ shouldHighlightGradient = true;
+ break;
+ default:
+ shouldHighlightGradient = false;
+ break;
}
- inputEl.highlightGradient = highlightGradient;
+ inputEl.highlightGradient = shouldHighlightGradient;
}
async function setTextColors(inputEl, overlayEl) {
@@ -492,10 +501,13 @@ const settingsDefinitions = [
},
{
id: 'sn0w.TextboxGradientColors',
- name: '[Sn0w] Custom Textbox Gradient Highlight',
- type: 'boolean',
- defaultValue: false,
- tooltip: 'Makes the textbox highlighting be a gradient between the first and last color based on the strength of the selection.',
+ 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 => {
@@ -512,6 +524,7 @@ const registerSetting = (settingDefinition) => {
const setting = app.ui.settings.addSetting({
id: settingDefinition.id,
name: settingDefinition.name,
+ options: settingDefinition.options,
type: settingDefinition.type,
defaultValue: settingDefinition.defaultValue,
tooltip: settingDefinition.tooltip,