diff --git a/README.md b/README.md index 2ead150..21bf527 100644 --- a/README.md +++ b/README.md @@ -277,3 +277,7 @@ Note: Uses [ImagesGrid](https://github.com/LEv145/images-grid-comfy-plugin) for My general text to image workflow, note that it uses several nodes from different custom nodes. ![T2I](./imgs/normal_workflow.png) + +# Known Issues + +* Embedding highlighting does not work on linux. diff --git a/sn0w.py b/sn0w.py index 40d6741..6c72c5f 100644 --- a/sn0w.py +++ b/sn0w.py @@ -16,6 +16,8 @@ import torch from server import PromptServer from aiohttp import web +import folder_paths + class ConfigReader: """ @@ -329,5 +331,20 @@ class MessageHolder: return int(message.strip()) except ValueError: - cls.logger.log(f"failed to parse '${message}' as ${'comma separated list of ints' if asList else 'int'}", "ERROR") + cls.logger.log(f"failed to parse '{message}' as {'comma separated list of ints' if asList else 'int'}", "ERROR") return [1] if asList else 1 + + +API_PREFIX = "/sn0w" + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/loras") +async def get_loras(request): + loras = folder_paths.get_filename_list("loras") + return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras))) + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/embeddings") +async def get_embeddings(request): + embeddings = folder_paths.get_filename_list("embeddings") + return web.json_response(list(map(lambda a: os.path.splitext(a)[0], embeddings))) diff --git a/src/character_select.py b/src/character_select.py index c5525c9..a4a7166 100644 --- a/src/character_select.py +++ b/src/character_select.py @@ -9,6 +9,10 @@ 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") async def handle_update_characters(request): @@ -38,15 +42,32 @@ class CharacterSelectNode: cls.load_characters(dir_path) + @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 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.SortBySeries", False): + 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, "web/settings/characters.json") + 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, "web/settings/custom_characters.json") + custom_json_path = os.path.join(dir_path, CUSTOM_CHARACTER_FILE_PATH) if os.path.exists(custom_json_path): with open(custom_json_path, "r", encoding="utf-8") as file: custom_character_data = json.load(file) @@ -64,7 +85,10 @@ class CharacterSelectNode: @staticmethod def extract_series_name(character_name): - return character_name.split("(")[-1].split(")")[0].strip() + series = character_name.split("(")[-1].split(")")[0].strip() + if character_name == series: + Logger().log(f"{character_name} has no series name.", "WARNING") + return series @classmethod def sort_characters(cls, force_sort=False): @@ -84,13 +108,7 @@ class CharacterSelectNode: @classmethod def INPUT_TYPES(cls): - if not cls.final_characters: # Check if initialization is needed - cls.initialize() - elif cls.cached_default_character_setting != ConfigReader.get_setting("sn0w.DisableDefaultCharacters", False): - cls.cached_default_character_setting = not cls.cached_default_character_setting - cls.initialize() - else: - cls.sort_characters() + cls.check_initialize() character_names = ["None"] + list(cls.final_characters) return { "required": { diff --git a/web/js/sn0w.js b/web/js/sn0w.js index 354914a..b610da8 100644 --- a/web/js/sn0w.js +++ b/web/js/sn0w.js @@ -333,7 +333,8 @@ export class SettingUtils { } if (!nodeDef) { - throw new Error(`Node definition with name ${nodeName} not found`); + console.error(`Node definition with name ${nodeName} not found`); + return; } // Find the specific node in the graph by name @@ -346,7 +347,8 @@ export class SettingUtils { } if (!node) { - throw new Error(`Node with name ${nodeName} not found in the graph`); + console.warn(`Node with name ${nodeName} not found in the graph`); + return; } for (let nodeNum in graphCanvas.graph._nodes) { diff --git a/web/js/textbox.js b/web/js/textbox.js index 6bd997f..1dae834 100644 --- a/web/js/textbox.js +++ b/web/js/textbox.js @@ -1,5 +1,6 @@ import { SettingUtils } from './sn0w.js'; import { app } from '../../../scripts/app.js'; +import { api } from '../../../scripts/api.js'; app.registerExtension({ name: 'sn0w.Textbox', @@ -11,6 +12,13 @@ app.registerExtension({ return textbox; }; + nodeType.prototype.updateHighlightType = 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]; @@ -41,54 +49,58 @@ app.registerExtension({ this.inputEl.inputEl.style.background = 'transparent'; - // Sync text initially and on input - this.inputEl.inputEl.addEventListener('input', () => { - syncText(this.inputEl.inputEl, this.overlayEl); - setOverlayStyle(this.inputEl, this.overlayEl); - }); + addEventListeners(this); + addObversers(this); - const observer = new MutationObserver(() => { - setOverlayPosition(this.inputEl, this.overlayEl); - }); - - observer.observe(this.inputEl.inputEl, { - attributes: true, - attributeFilter: ['style'], - childList: true, - subtree: true, - characterData: true, - }); - - const parentObserver = new MutationObserver(() => { - if (!document.contains(this.inputEl.inputEl)) { - this.overlayEl.remove(); - } - }); - - parentObserver.observe(this.inputEl.inputEl.parentNode, { - childList: true, - }); - - this.inputEl.inputEl.addEventListener('keydown', (event) => { - if ( - event.ctrlKey && - (event.key === 'ArrowUp' || event.key === 'ArrowDown') - ) { - setTimeout(() => { - syncText(this.inputEl.inputEl, this.overlayEl); - }, 10); - } - }); - - setTimeout(() => { - setOverlayPosition(this.inputEl, this.overlayEl); - setOverlayStyle(this.inputEl, this.overlayEl); - }, 10); + 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'); if (highlightGradient === null) { @@ -159,7 +171,85 @@ app.registerExtension({ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } - async function syncText(inputEl, overlayEl) { + 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; @@ -169,12 +259,14 @@ app.registerExtension({ const loraColor = colors ? colors[0] : undefined; if (!colors || !errorColor || !loraColor || shouldHighlightGradient == undefined) { - setTimeout(() => syncText(inputEl, overlayEl), 5); + if (tries < 5) { + setTimeout(() => syncText(inputEl, overlayEl, tries++), tries * 5); + } return; } let uniqueIdCounter = 0; - const generateUniqueId = () => `unique-span-${uniqueIdCounter++}`; + const generateUniqueId = (type = "") => `span-${uniqueIdCounter++}${type}`; let nestingLevel = 0; let highlightedText = ''; @@ -182,8 +274,27 @@ app.registerExtension({ 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]; + const char = text[i].toLowerCase(); // Handle escape characters if (char === '\\' && i + 1 < text.length && (text[i + 1] === '(' || text[i + 1] === ')')) { @@ -194,53 +305,81 @@ app.registerExtension({ // Handle wrong escape characters if (char === '/' && i + 1 < text.length && (text[i + 1] === '(' || text[i + 1] === ')')) { highlightedText += text.slice(lastIndex, i) + `/`; - console.error(`Try replacing "${char}" at char ${i} with "\\"`); + console.error(`Replace "${char}" at char ${i} with "\\"`); lastIndex = i + 1; continue; } let color = colors[nestingLevel % colors.length]; - let uniqueId = generateUniqueId(); + let uniqueId = generateUniqueId(charToType(char)); if (inputEl.highlightGradient === true) { color = `id-${uniqueId}`; } switch(char) { case '(': + case '<': highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}`; - spanStack.push({ id: uniqueId, start: highlightedText.length, originalSpan: ``, nestingLevel }); + spanStack.push({ id: uniqueId, start: highlightedText.length, originalSpan: ``, nestingLevel, originalColor: color, originalChar: char }); nestingLevel++; lastIndex = i + 1; break; - case '<': - uniqueId = `${uniqueId}-lora`; - color = `id-${uniqueId}`; - highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}`; - spanStack.push({ id: uniqueId, start: highlightedText.length - 3, originalSpan: ``, nestingLevel }); - nestingLevel++; - lastIndex = i + 1; + 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 = `${escapeHtml(embeddingText)}`; + highlightedText += text.slice(lastIndex, i) + wrappedEmbedding; + lastIndex = i + embeddingText.length; + } break; case ')': case '>': if (nestingLevel > 0) { - highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}`; - const { id } = spanStack.pop(); - nestingLevel--; + const { id, originalColor, originalChar } = spanStack[spanStack.length - 1]; + if (charPairs[originalChar] === char) { + spanStack.pop(); + highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}`; + nestingLevel--; - 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); - } else { - uniqueIdMap.set(id, interpolateColor(colors[0], colors[colors.length - 1], 0.5)); + // 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; } - lastIndex = i + 1; } break; } @@ -257,8 +396,7 @@ app.registerExtension({ const errorSpanTag = ``; if (originalSpan) { - const newText = highlightedText.slice(start - (originalSpan.length + 1), highlightedText.length).replace(originalSpan, errorSpanTag); - highlightedText = highlightedText.slice(0, start - originalSpan.length + 1) + newText; + highlightedText = highlightedText.replace(originalSpan, errorSpanTag); highlightedText += ``; } } @@ -266,10 +404,25 @@ app.registerExtension({ } // Apply the updated colors to the highlighted text - uniqueIdMap.forEach((newColor, id) => { - highlightedText = highlightedText.replace(`background-color: id-${id}`, `background-color: ${newColor}`); + 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, `${trimmedSegment}`) + } + }).join(''); + overlayEl.innerHTML = highlightedText; } @@ -305,9 +458,27 @@ app.registerExtension({ overlayEl.style.wordWrap = 'break-word'; } + async function setValidFiles(inputEl) { + inputEl.validLoras = await getValidFiles("loras"); + inputEl.validEmbeddings = await getValidFiles("embeddings"); + } + + async function getValidFiles(type) { + const apiRequest = await api.fetchApi(`${SettingUtils.API_PREFIX}/${type}`, { + method: "GET", + }) + if (!apiRequest.ok) { + console.error('API request failed:', apiRequest.statusText); + } else { + const responseBody = await apiRequest.json(); + return responseBody; + } + } + nodeType.prototype.onNodeCreated = function () { this.populate(); setTextColors(this.inputEl.inputEl, this.overlayEl); + setValidFiles(this.inputEl.inputEl); this.getTextboxText = this.getTextboxText.bind(this); }; } @@ -321,6 +492,12 @@ const settingsDefinitions = [ 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', @@ -328,6 +505,12 @@ const settingsDefinitions = [ 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.', + onChange: () => { + const nodes = app.graph._nodes.filter(node => node.type === 'Copy/Paste Textbox'); + nodes.forEach(node => { + node.updateHighlightType(); + }); + }, } ]; @@ -342,6 +525,7 @@ const registerSetting = (settingDefinition) => { defaultValue: settingDefinition.defaultValue, tooltip: settingDefinition.tooltip, attrs: settingDefinition.attrs, + onChange: settingDefinition.onChange, }); }, };