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.

+
+# 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,
});
},
};