From ae41f8068e8ff8d5ea9c0af0cb690b89d4e50bca Mon Sep 17 00:00:00 2001 From: Apolyon6k Date: Wed, 17 Sep 2025 20:22:53 +0200 Subject: [PATCH] extended the dynamic prompt node to handle yaml and json files --- __init__.py | 2 +- nodes/prompt.py | 263 +++++++++++++++++++++++++++++++----------------- pyproject.toml | 2 +- 3 files changed, 171 insertions(+), 96 deletions(-) diff --git a/__init__.py b/__init__.py index f30738b..90e16c0 100644 --- a/__init__.py +++ b/__init__.py @@ -12,7 +12,7 @@ NODE_CLASS_MAPPINGS = { "RandomFloatToString": KN_RandomFloatToString, "GetFileCountInOutputFolder": KN_GetFileCountInOutputFolder, "LoadCSV": KN_CSV_Reader, - "DynamicPromptNode": DynamicPromptNode, + "DynamicPromptNode": KN_DynamicPromptNode, } # A dictionary that contains the friendly/humanly readable titles for the nodes diff --git a/nodes/prompt.py b/nodes/prompt.py index 15f38e4..4fa2052 100644 --- a/nodes/prompt.py +++ b/nodes/prompt.py @@ -1,33 +1,40 @@ -# raw GPT output, unfixed and untested! import random import re -import folder_paths from pathlib import Path -import os from itertools import product +import json +import yaml +import folder_paths -class DynamicPromptNode: + +class KN_DynamicPromptNode: """ Dynamic prompt generator with: - Inline random options {option1|option2|...} - - Wildcards from ComfyUI wildcards folder: __filename__ - - Nested placeholders - - Sequential combinatorial output with optional reset on template change + - Wildcards from ComfyUI wildcards folder: __category/subcategory__ or __category/*__ + - Sequential combinatorial output + - Optional reset_on_change + - Cached YAML/JSON wildcard loading for performance """ - - #wildcard_folder = os.path.join(folder_paths.base_path) - wildcard_folder = Path(folder_paths.base_path,"wildcards") - combinatorial_index = 0 - last_template = None + + # Updated wildcard folder + wildcard_folder = Path(folder_paths.base_path, "wildcards") + + # Cache for parsed YAML/JSON files: {filename: parsed_data} + _wildcard_cache = {} @classmethod def INPUT_TYPES(cls): return { "required": { - "template": ("STRING", {"default": "A {happy|sad} __subjects__ with __adjective__","multiline": True, "dynamicPrompts": True}), + "template": ("STRING", { + "default": "A {happy|sad} __videogamechars/male__ with __adjective__", + "multiline": True, + "dynamicPrompts": True + }), "seed": ("INT", {"default": -1}), "mode": (["random", "sequential"], {"default": "random"}), - "reset_on_change": ("BOOL", {"default": True}), + "reset_on_change": ("BOOLEAN", {"default": True}), } } @@ -36,98 +43,166 @@ class DynamicPromptNode: FUNCTION = "generate" CATEGORY = "KaleidiaNodes/PromptNodes" + # ----------------------- + # Instance state + # ----------------------- + def _ensure_state(self): + if not hasattr(self, "combinatorial_index"): + self.combinatorial_index = 0 + self.last_template = None + def generate(self, template, seed=-1, mode="random", reset_on_change=True): + self._ensure_state() + if seed >= 0: random.seed(seed) else: random.seed() - # Reset combinatorial index if template changed and option enabled if reset_on_change and template != self.last_template: self.combinatorial_index = 0 self.last_template = template if mode == "random": - # Recursive random replacement - def resolve(text, depth=0): - if depth > 20: - return text - - def replace_inline(match): - options = [opt.strip() for opt in match.group(1).split("|")] - choice = random.choice(options) - return resolve(choice, depth + 1) - - text = re.sub(r"\{([^{}]+)\}", replace_inline, text) - - def replace_wildcard(match): - filename = match.group(1).strip() - file_path = self.wildcard_folder / f"{filename}.txt" - if file_path.exists(): - with open(file_path, "r", encoding="utf-8") as f: - lines = [line.strip() for line in f if line.strip()] - if lines: - choice = random.choice(lines) - return resolve(choice, depth + 1) - return f"__{filename}__" - - text = re.sub(r"__([A-Za-z0-9_]+)__", replace_wildcard, text) - return text - - return (resolve(template),) - + return (self._resolve_random(template),) elif mode == "sequential": - # Parse template into placeholders - placeholder_pattern = r"\{([^{}]+)\}|__([A-Za-z0-9_]+)__" - tokens = [] - matches = list(re.finditer(placeholder_pattern, template)) - last_index = 0 - option_lists = [] - - for match in matches: - start, end = match.span() - tokens.append(template[last_index:start]) - last_index = end - - if match.group(1): - options = [opt.strip() for opt in match.group(1).split("|")] - else: - filename = match.group(2).strip() - file_path = self.wildcard_folder / f"{filename}.txt" - if file_path.exists(): - with open(file_path, "r", encoding="utf-8") as f: - options = [line.strip() for line in f if line.strip()] - else: - options = [f"__{filename}__"] - - tokens.append(None) - option_lists.append(options) - - tokens.append(template[last_index:]) - - if not option_lists: - return (template,) - - # Compute combination for current index - all_combinations = list(product(*option_lists)) - combo_index = self.combinatorial_index % len(all_combinations) - self.combinatorial_index += 1 - - selected_combo = all_combinations[combo_index] - prompt = "" - combo_idx = 0 - for tok in tokens: - if tok is None: - prompt += selected_combo[combo_idx] - combo_idx += 1 - else: - prompt += tok - - return (prompt,) - + return (self._resolve_sequential(template),) return ("",) + # ----------------------- + # Random / Sequential + # ----------------------- + def _resolve_random(self, text, depth=0): + if depth > 20: + return text -# Register node -# NODE_CLASS_MAPPINGS.update({"DynamicPromptNode": DynamicPromptNode}) -# NODE_DISPLAY_NAME_MAPPINGS.update({"DynamicPromptNode": "Dynamic Prompt Generator"}) + # inline {a|b|c} + def replace_inline(match): + options = [opt.strip() for opt in match.group(1).split("|")] + choice = random.choice(options) + return self._resolve_random(choice, depth + 1) + + text = re.sub(r"\{([^{}]+)\}", replace_inline, text) + + # wildcards __category/subcategory__ or __category/*__ + def replace_wildcard(match): + path = match.group(1).strip() + options = self._load_category_wildcard(path) + if options: + choice = random.choice(options) + return self._resolve_random(choice, depth + 1) + return f"__{path}__" + + text = re.sub(r"__([A-Za-z0-9_/*]+)__", replace_wildcard, text) + return text + + def _resolve_sequential(self, template): + placeholder_pattern = r"\{([^{}]+)\}|__([A-Za-z0-9_/*]+)__" + tokens = [] + matches = list(re.finditer(placeholder_pattern, template)) + last_index = 0 + option_lists = [] + + for match in matches: + start, end = match.span() + tokens.append(template[last_index:start]) + last_index = end + + if match.group(1): + options = [opt.strip() for opt in match.group(1).split("|")] + else: + path = match.group(2).strip() + options = self._load_category_wildcard(path) + if not options: + options = [f"__{path}__"] + + tokens.append(None) + option_lists.append(options) + + tokens.append(template[last_index:]) + + if not option_lists: + return template + + all_combinations = list(product(*option_lists)) + combo_index = self.combinatorial_index % len(all_combinations) + self.combinatorial_index += 1 + + selected_combo = all_combinations[combo_index] + prompt = "" + combo_idx = 0 + for tok in tokens: + if tok is None: + prompt += selected_combo[combo_idx] + combo_idx += 1 + else: + prompt += tok + + return prompt + + # ----------------------- + # Wildcard helpers + # ----------------------- + def _flatten_data(self, data): + results = [] + if isinstance(data, dict): + for v in data.values(): + results.extend(self._flatten_data(v)) + elif isinstance(data, list): + for v in data: + results.extend(self._flatten_data(v)) + elif isinstance(data, str): + results.append(data.strip()) + return results + + def _load_category_wildcard(self, category_path): + if "/" in category_path: + root, subcat = category_path.split("/", 1) + else: + root, subcat = category_path, None + + root = root.lower() + subcat = subcat.lower() if subcat else None + + options = [] + + # TXT file + txt_file = self.wildcard_folder / f"{category_path}.txt" + if txt_file.exists(): + with open(txt_file, "r", encoding="utf-8") as f: + options.extend([line.strip() for line in f if line.strip()]) + + # YAML / JSON files + for ext in ("yaml", "yml", "json"): + for f in self.wildcard_folder.glob(f"*.{ext}"): + if f in self._wildcard_cache: + data = self._wildcard_cache[f] + else: + with open(f, "r", encoding="utf-8") as fh: + if ext == "json": + data = json.load(fh) + else: + data = yaml.safe_load(fh) + self._wildcard_cache[f] = data + + if not data: + continue + + # make root keys lowercase for case-insensitive match + data_lower = {k.lower(): v for k, v in data.items() if isinstance(k, str)} + + if root in data_lower: + node = data_lower[root] + + # lowercase subcategory mapping if dict + if isinstance(node, dict): + node_lower = {k.lower(): v for k, v in node.items() if isinstance(k, str)} + if subcat == "*" or subcat is None: + options.extend(self._flatten_data(node_lower)) + elif subcat in node_lower: + options.extend(self._flatten_data(node_lower[subcat])) + else: + # if node is list or str at root + options.extend(self._flatten_data(node)) + + return options \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 08e1817..ae58940 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "ComfyUI_KaleidiaNodes" # Unique identifier for your node. Immutable after creation. description = "A simple set of nodes to make things easier. String Nodes and Files nodes." -version = "1.1.0" # Custom Node version. Must be semantically versioned. +version = "1.1.1" # Custom Node version. Must be semantically versioned. license = { file = "LICENSE.txt" } dependencies = [] # Filled in from requirements.txt