import logging import math import os import re import textwrap import time from collections import namedtuple from enum import Enum from typing import Any, Callable, Optional import lark import numpy as np from ppp_hosts import SUPPORTED_APPS # pylint: disable=import-error from ppp_logging import DEBUG_LEVEL # pylint: disable=import-error from ppp_wildcards import PPPWildcard, PPPWildcards # pylint: disable=import-error from ppp_enmappings import PPPENMappingVariant, PPPExtraNetworkMappings # pylint: disable=import-error class PPPInterrupt(Exception): """ Custom exception to handle interruptions in the PromptPostProcessor. This exception can be raised to stop the processing of prompts. """ def __init__(self, message: str = "Processing interrupted.", pos_prefix: str = "", neg_prefix: str = ""): super().__init__(message) self.message = message self.pos_prefix = pos_prefix self.neg_prefix = neg_prefix class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-instance-attributes """ The PromptPostProcessor class is responsible for processing and manipulating prompt strings. """ @staticmethod def get_version_from_pyproject() -> str: """ Reads the version from the pyproject.toml file. Returns: str: The version string. """ version_str = "0.0.0" try: pyproject_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pyproject.toml") with open(pyproject_path, "r", encoding="utf-8") as file: for line in file: if line.startswith("version = "): version_str = line.split("=")[1].strip().strip('"') break except Exception as e: # pylint: disable=broad-exception-caught logging.getLogger().exception(e) return version_str NAME = "Prompt Post-Processor" VERSION = get_version_from_pyproject() class IFWILDCARDS_CHOICES(Enum): ignore = "ignore" remove = "remove" warn = "warn" stop = "stop" class ONWARNING_CHOICES(Enum): warn = "warn" stop = "stop" DEFAULT_STN_SEPARATOR = ", " DEFAULT_VARIANTS_DEFINITIONS = "pony(sdxl)=pony,pny,pdxl\nillustrious(sdxl)=illustrious,illust,ilxl" DEFAULT_CHOICE_SEPARATOR = ", " WILDCARD_WARNING = '(WARNING TEXT "INVALID WILDCARD" IN BRIGHT RED:1.5)\nBREAK ' WILDCARD_STOP = "INVALID WILDCARD! {0}\nBREAK " UNPROCESSED_STOP = "UNPROCESSED CONSTRUCTS!\nBREAK " INVALID_CONTENT_STOP = "INVALID CONTENT! {0}\nBREAK " SUPPORTED_MODELS = [ "sd1", "sd2", "sdxl", "sd3", "flux", "auraflow", ] def __init__( self, logger: logging.Logger, interrupt: Optional[Callable], env_info: dict[str, Any], options: Optional[dict[str, Any]] = None, grammar_content: Optional[str] = None, wildcards_obj: PPPWildcards = None, extranetwork_mappings_obj: PPPExtraNetworkMappings = None, ): """ Initializes the PPP object. Args: logger: The logger object. interrupt: The interrupt function. env_info: A dictionary with information for the environment and loaded model. options: Optional. The options dictionary for configuring PPP behavior. grammar_content: Optional. The grammar content to be used for parsing. wildcards_obj: Optional. The wildcards object to be used for processing wildcards. extranetwork_mappings_obj: Optional. The extranetwork mappings object to be used for processing. """ self.logger = logger self.rng = np.random.default_rng() # gets seeded on each process prompt call self.interrupt_callback = interrupt self.options = options self.env_info = env_info self.wildcard_obj = wildcards_obj self.extranetwork_mappings_obj = extranetwork_mappings_obj # General options self.debug_level = DEBUG_LEVEL(options.get("debug_level", DEBUG_LEVEL.none.value)) self.gen_onwarning = self.ONWARNING_CHOICES(options.get("on_warning", self.ONWARNING_CHOICES.warn.value)) variants_definitions_option = str(options.get("variants_definitions", self.DEFAULT_VARIANTS_DEFINITIONS)) self.variants_definitions = {} if variants_definitions_option: lines = variants_definitions_option.splitlines() for line in lines: if "=" in line: model_tag, elements = line.split("=", 1) model_name, model_type = re.match(r"(\w+)(?:\((\w+)\))?", model_tag).groups() if model_type is not None and model_type not in self.SUPPORTED_MODELS: self.logger.warning( f"Unsupported model type '{model_type}' in definition for variant '{model_name}'." ) elif model_name in self.SUPPORTED_MODELS: self.logger.warning(f"Invalid model name in definition for variant '{model_name}'.") else: self.variants_definitions[model_name.strip()] = ( model_type or "", [element.strip() for element in elements.split(",")], ) # Wildcards options self.wil_process_wildcards = options.get("process_wildcards", True) self.wil_keep_choices_order = options.get("keep_choices_order", False) self.wil_choice_separator = options.get("choice_separator", self.DEFAULT_CHOICE_SEPARATOR) self.wil_ifwildcards = self.IFWILDCARDS_CHOICES( options.get("if_wildcards", self.IFWILDCARDS_CHOICES.stop.value) ) # Send to negative options self.stn_ignore_repeats = options.get("stn_ignore_repeats", True) self.stn_separator = options.get("stn_separator", self.DEFAULT_STN_SEPARATOR) # Cleanup options self.cup_extraspaces = options.get("cleanup_extra_spaces", True) self.cup_emptyconstructs = options.get("cleanup_empty_constructs", True) self.cup_extraseparators = options.get("cleanup_extra_separators", True) self.cup_extraseparators2 = options.get("cleanup_extra_separators2", True) self.cup_extraseparators_include_eol = options.get("cleanup_extra_separators_include_eol", False) self.cup_breaks = options.get("cleanup_breaks", True) self.cup_breaks_eol = options.get("cleanup_breaks_eol", False) self.cup_ands = options.get("cleanup_ands", True) self.cup_ands_eol = options.get("cleanup_ands_eol", False) self.cup_extranetworktags = options.get("cleanup_extranetwork_tags", False) self.cup_mergeattention = options.get("cleanup_merge_attention", True) # Remove options self.rem_removeextranetworktags = options.get("remove_extranetwork_tags", False) # if self.debug_level != DEBUG_LEVEL.none: # self.logger.info(f"Detected environment info: {env_info}") # Process with lark (debug with https://www.lark-parser.org/ide/) if grammar_content is None: grammar_filename = os.path.join(os.path.dirname(os.path.realpath(__file__)), "grammar.lark") with open(grammar_filename, "r", encoding="utf-8") as file: grammar_content = file.read() # Preprocess grammar content for conditional compilation self.parser_full_only_old = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": False, "ALLOW_WILDCARDS": False, "ALLOW_CHOICES": False, "ALLOW_COMMVARS": False, }, ), propagate_positions=True, ) grammar_content_full = self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": True, "ALLOW_CHOICES": True, "ALLOW_COMMVARS": True, }, ) self.parser_complete_full = lark.Lark( grammar_content_full, propagate_positions=True, ) self.parser_complete_wc_ch = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": True, "ALLOW_CHOICES": True, "ALLOW_COMMVARS": False, }, ), propagate_positions=True, ) self.parser_complete_wc_cv = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": True, "ALLOW_CHOICES": False, "ALLOW_COMMVARS": True, }, ), propagate_positions=True, ) self.parser_complete_ch_cv = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": False, "ALLOW_CHOICES": True, "ALLOW_COMMVARS": True, }, ), propagate_positions=True, ) self.parser_complete_wc = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": True, "ALLOW_CHOICES": False, "ALLOW_COMMVARS": False, }, ), propagate_positions=True, ) self.parser_complete_ch = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": False, "ALLOW_CHOICES": True, "ALLOW_COMMVARS": False, }, ), propagate_positions=True, ) self.parser_complete_cv = lark.Lark( self.__preprocess_grammar( grammar_content, { "ALLOW_NEW_CONTENT": True, "ALLOW_WILDCARDS": False, "ALLOW_CHOICES": False, "ALLOW_COMMVARS": True, }, ), propagate_positions=True, ) # Partial parsers self.parser_content = lark.Lark( grammar_content_full, propagate_positions=True, start="content", ) self.parser_choice = lark.Lark( grammar_content_full, propagate_positions=True, start="choice", ) self.parser_choicesoptions = lark.Lark( grammar_content_full, propagate_positions=True, start="choicesoptions", ) self.parser_condition = lark.Lark( grammar_content_full, propagate_positions=True, start="condition", ) self.parser_choicevalue = lark.Lark( grammar_content_full, propagate_positions=True, start="choicevalue", ) self.__init_sysvars() self.user_variables = {} self.echoed_variables = {} def __preprocess_grammar(self, grammar_content: str, options: dict[str, bool]) -> str: """ Preprocesses the grammar content to handle conditional compilation directives. Args: grammar_content (str): The raw grammar content. options (dict[str,bool]): Options for preprocessing. Returns: str: The preprocessed grammar content. """ lines = grammar_content.split("\n") result_lines = [] skip_current_block = [] all_blocks_skipped = [] def evaluate_conditions(conditions: list[str]) -> bool: """ Evaluates the conditions based on the provided options. Allows for negation with '!' prefix. Args: conditions (list[str]): List of conditions to evaluate. Returns: bool: True if all conditions are met, False otherwise. """ r = True for condition in conditions: if condition.startswith("!"): r = r and not options.get(condition[1:], False) else: r = r and options.get(condition, True) return r for line in lines: stripped_line = line.strip() if stripped_line.startswith("//#if"): # Extract condition from the #if directive conditions = stripped_line[5:].strip().split(" ") # Evaluate the conditions skip_current_block.append(not evaluate_conditions(conditions)) all_blocks_skipped.append(skip_current_block[-1]) continue if stripped_line.startswith("//#elif"): if not skip_current_block: self.logger.warning("Unmatched //#elif directive found in grammar content.") elif all_blocks_skipped[-1]: # Extract condition from the #elif directive conditions = stripped_line[7:].strip().split(" ") # Evaluate the conditions skip_current_block[-1] = not evaluate_conditions(conditions) if not skip_current_block[-1]: all_blocks_skipped[-1] = False else: skip_current_block[-1] = True continue if stripped_line.startswith("//#else"): if not skip_current_block: self.logger.warning("Unmatched //#else directive found in grammar content.") elif all_blocks_skipped[-1]: skip_current_block[-1] = False else: skip_current_block[-1] = True continue if stripped_line.startswith("//#endif"): if not skip_current_block: self.logger.warning("Unmatched //#endif directive found in grammar content.") else: skip_current_block.pop() all_blocks_skipped.pop() continue # Include the line if we're not skipping any current block if not any(skip_current_block): result_lines.append(stripped_line) # Check for unclosed blocks at the end if skip_current_block: self.logger.warning( f"Found {len(skip_current_block)} unclosed conditional directive(s) at the end of the file" ) return "\n".join(result_lines) def interrupt(self): if self.interrupt_callback is not None: self.interrupt_callback() def format_output(self, text: str) -> str: """ Formats the output text by encoding it using unicode_escape and decoding it using utf-8. Args: text (str): The input text to be formatted. Returns: str: The formatted output text. """ return text.encode("unicode_escape").decode("utf-8") def is_comfy_ui(self) -> bool: """ Checks if the current environment is ComfyUI. Returns: bool: True if the environment is ComfyUI, False otherwise. """ return self.env_info.get("app", "") == SUPPORTED_APPS.comfyui.value def __init_sysvars(self): """ Initializes the system variables. """ self.system_variables = {} sdchecks = {x: self.env_info.get("is_" + x, False) for x in self.SUPPORTED_MODELS} sdchecks.update({"": True}) self.system_variables["_model"] = [k for k, v in sdchecks.items() if v][0] self.system_variables["_sd"] = self.system_variables["_model"] # deprecated model_filename = self.env_info.get("model_filename", "") self.system_variables["_sdfullname"] = model_filename # deprecated self.system_variables["_modelfullname"] = model_filename self.system_variables["_sdname"] = os.path.basename(model_filename) # deprecated self.system_variables["_modelname"] = os.path.basename(model_filename) self.system_variables["_modelclass"] = self.env_info.get("model_class", "") is_models = { model_name: (model_type_and_substrings[0] == "" or sdchecks.get(model_type_and_substrings[0], False)) and any(s in model_filename.lower() for s in model_type_and_substrings[1]) for model_name, model_type_and_substrings in self.variants_definitions.items() if model_name not in self.SUPPORTED_MODELS } is_models_true = [k for k, v in is_models.items() if v] if len(is_models_true) > 1: self.logger.warning( f"Multiple model variants detected at the same time in the filename!: {', '.join(is_models_true)}" ) self.system_variables.update({"_is_" + x: y for x, y in is_models.items()}) for x in sdchecks.keys(): if x != "": self.system_variables["_is_" + x] = sdchecks[x] self.system_variables["_is_pure_" + x] = sdchecks[x] and not any(is_models.values()) self.system_variables["_is_variant_" + x] = sdchecks[x] and any(is_models.values()) # special cases self.system_variables["_is_sd"] = sdchecks["sd1"] or sdchecks["sd2"] or sdchecks["sdxl"] or sdchecks["sd3"] is_ssd = self.env_info.get("is_ssd", False) self.system_variables["_is_ssd"] = is_ssd self.system_variables["_is_sdxl_no_ssd"] = sdchecks["sdxl"] and not is_ssd # backcompatibility (but the modern one to use would be _is_pure_sdxl) self.system_variables["_is_sdxl_no_pony"] = sdchecks["sdxl"] and not self.system_variables.get( "_is_pony", False ) def __add_to_insertion_points( self, negative_prompt: str, add_at_insertion_point: list[str], insertion_at: list[tuple[int, int]] ) -> str: """ Adds the negative prompt to the insertion points. Args: negative_prompt (str): The negative prompt to be added. add_at_insertion_point (list): A list of insertion points. insertion_at (list): A list of insertion blocks. Returns: str: The modified negative prompt. """ ordered_range = sorted( range(10), key=lambda x: insertion_at[x][0] if insertion_at[x] is not None else float("-inf"), reverse=True ) for n in ordered_range: if insertion_at[n] is not None: ipp = insertion_at[n][0] ipl = insertion_at[n][1] - insertion_at[n][0] if negative_prompt[ipp - len(self.stn_separator) : ipp] == self.stn_separator: ipp -= len(self.stn_separator) # adjust for existing start separator ipl += len(self.stn_separator) add_at_insertion_point[n].insert(0, negative_prompt[:ipp]) if negative_prompt[ipp + ipl : ipp + ipl + len(self.stn_separator)] == self.stn_separator: ipl += len(self.stn_separator) # adjust for existing end separator endPart = negative_prompt[ipp + ipl :] if len(endPart) > 0: add_at_insertion_point[n].append(endPart) negative_prompt = self.stn_separator.join(add_at_insertion_point[n]) else: ipp = 0 if negative_prompt.startswith(self.stn_separator): ipp = len(self.stn_separator) add_at_insertion_point[n].append(negative_prompt[ipp:]) negative_prompt = self.stn_separator.join(add_at_insertion_point[n]) return negative_prompt def __add_to_start(self, negative_prompt: str, add_at_start: list[str]) -> str: """ Adds the elements in `add_at_start` list to the start of the `negative_prompt` string. Args: negative_prompt (str): The original negative prompt string. add_at_start (list): The list of elements to be added at the start of the negative prompt. Returns: str: The updated negative prompt string with the elements added at the start. """ if len(negative_prompt) > 0: ipp = 0 if negative_prompt.startswith(self.stn_separator): ipp = len(self.stn_separator) # adjust for existing end separator add_at_start.append(negative_prompt[ipp:]) negative_prompt = self.stn_separator.join(add_at_start) return negative_prompt def __add_to_end(self, negative_prompt: str, add_at_end: list[str]) -> str: """ Adds the elements in `add_at_end` list to the end of `negative_prompt` string. Args: negative_prompt (str): The original negative prompt string. add_at_end (list): The list of elements to be added at the end of `negative_prompt`. Returns: str: The updated negative prompt string with elements added at the end. """ if len(negative_prompt) > 0: ipl = len(negative_prompt) if negative_prompt.endswith(self.stn_separator): ipl -= len(self.stn_separator) # adjust for existing start separator add_at_end.insert(0, negative_prompt[:ipl]) negative_prompt = self.stn_separator.join(add_at_end) return negative_prompt def __cleanup(self, text: str) -> str: """ Trims the given text based on the specified cleanup options. Args: text (str): The text to be cleaned up. Returns: str: The resulting text. """ escapedSeparator = re.escape(self.stn_separator) optwhitespace = r"\s*" if self.cup_extraseparators_include_eol else r"[ \t\v\f]*" optwhitespace_separator = optwhitespace + escapedSeparator + optwhitespace optwhitespace_comma = optwhitespace + "," + optwhitespace sep_options = [(optwhitespace_separator, self.stn_separator)] # sendtonegative separator if optwhitespace_comma != optwhitespace_separator: sep_options.append((optwhitespace_comma, ", ")) # regular comma separator for sep, replacement in sep_options: if self.cup_extraseparators: # collapse separators text = re.sub(r"(?:" + sep + r"){2,}", replacement, text) # remove separator after starting parenthesis or bracket text = re.sub( r"(" + sep + r"[([])(?:" + sep + r")+", r"\1", text, ) # remove before colon or ending parenthesis or bracket text = re.sub( r"(?:" + sep + r")+([:)\]]" + sep + r")", r"\1", text, ) if self.cup_extraseparators2: # remove at start of prompt or line text = re.sub(r"^(?:" + sep + r")+", "", text, flags=re.MULTILINE) # remove at end of prompt or line text = re.sub(r"(?:" + sep + r")+$", "", text, flags=re.MULTILINE) if self.cup_breaks_eol: # replace spaces before break with EOL text = re.sub(r"[, ]+BREAK\b", "\nBREAK", text) if self.cup_breaks: # collapse separators and commas before BREAK text = re.sub(r"[, ]+BREAK\b", " BREAK", text) # collapse separators and commas after BREAK text = re.sub(r"\bBREAK[, ]+", "BREAK ", text) # collapse separators and commas around BREAK text = re.sub(r"[, ]+BREAK[, ]+", " BREAK ", text) # collapse BREAKs text = re.sub(r"\bBREAK(?:\s+BREAK)+\b", " BREAK ", text) # remove spaces between start of line and BREAK text = re.sub(r"^[ ]+BREAK\b", "BREAK", text, flags=re.MULTILINE) # remove spaces between BREAK and end of line text = re.sub(r"\bBREAK[ ]+$", "BREAK", text, flags=re.MULTILINE) # remove at start of prompt text = re.sub(r"\A(?:\s*BREAK\b\s*)+", "", text) # remove at end of prompt text = re.sub(r"(?:\s*\bBREAK\s*)+\Z", "", text) if self.cup_ands: # collapse ANDs with space after text = re.sub(r"\bAND(?:\s+AND)+\s+", "AND ", text) # collapse ANDs without space after text = re.sub(r"\bAND(?:\s+AND)+\b", "AND", text) # collapse separators and spaces before ANDs text = re.sub(r"[, ]+AND\b", " AND", text) # collapse separators and spaces after ANDs text = re.sub(r"\bAND[, ]+", "AND ", text) # remove at start of prompt text = re.sub(r"\A(?:AND\b\s*)+", "", text) # remove at end of prompt text = re.sub(r"(\s*\bAND)+\Z", "", text) if self.cup_extranetworktags: # remove spaces before < text = re.sub(r"\B\s+<(?!!)", "<", text) # remove spaces after > text = re.sub(r">\s+\B", ">", text) if self.cup_extraspaces: # remove spaces before comma text = re.sub(r"[ ]+,", ",", text) # remove spaces at end of line text = re.sub(r"[ ]+$", "", text, flags=re.MULTILINE) # remove spaces at start of line text = re.sub(r"^[ ]+", "", text, flags=re.MULTILINE) # remove extra whitespace after starting parenthesis or bracket text = re.sub(r"([,\.;\s]+[([])\s+", r"\1", text) # remove extra whitespace before ending parenthesis or bracket text = re.sub(r"\s+([)\]][,\.;\s]+)", r"\1", text) # remove empty lines text = re.sub(r"(?:^|\n)[ ]*\n", "\n", text) text = re.sub(r"\n[ ]*\n$", "\n", text) # collapse spaces text = re.sub(r"[ ]{2,}", " ", text) # remove spaces at start and end text = text.strip() return text def __get_best_parser(self, prompt: str) -> tuple[lark.Lark, str]: """ Checks the prompt and returns the best parser to use based on its content. Args: prompt (str): The prompt to check. Returns: tuple[lark.Lark, str]: The best parser and its description. """ tests = { "ALLOW_WILDCARDS": re.search(r"(?= 0: raise PPPInterrupt( "Found unprocessed constructs!", self.UNPROCESSED_STOP if prompt.find("= 0 else "", self.UNPROCESSED_STOP if negative_prompt.find("= 0 else "", ) return prompt, negative_prompt, all_variables except PPPInterrupt as e: self.logger.error(e.message) if e.pos_prefix: prompt = e.pos_prefix + prompt if e.neg_prefix: negative_prompt = e.neg_prefix + negative_prompt self.logger.error("Interrupting!") self.interrupt() return prompt, negative_prompt, all_variables except Exception as e: # pylint: disable=broad-exception-caught self.logger.exception(e) return original_prompt, original_negative_prompt, all_variables def parse_prompt(self, prompt_description: str, prompt: str, parser: lark.Lark, raise_parsing_error: bool = False): """ Parses a prompt using the specified parser. Args: prompt_description (str): The description of the prompt. prompt (str): The prompt to be parsed. parser (lark.Lark): The parser to be used. raise_parsing_error (bool): Whether to raise a parsing error. Returns: Tree: The parsed prompt. """ t1 = time.monotonic_ns() parsed_prompt = None try: if self.debug_level == DEBUG_LEVEL.full: self.logger.debug(self.format_output(f"Parsing {prompt_description}: '{prompt}'")) parsed_prompt = parser.parse(prompt) # we store the contents so we can use them later even if the meta position is not valid anymore if isinstance(parsed_prompt, lark.Tree): for n in parsed_prompt.iter_subtrees(): if isinstance(n, lark.Tree): if n.meta.empty: n.meta.content = "" else: n.meta.content = prompt[n.meta.start_pos : n.meta.end_pos] except lark.exceptions.UnexpectedInput: if raise_parsing_error: raise self.logger.exception(self.format_output(f"Parsing failed on prompt!: {prompt}")) t2 = time.monotonic_ns() if self.debug_level == DEBUG_LEVEL.full: self.logger.debug(f"Parse {prompt_description} time: {(t2 - t1) / 1_000_000_000:.3f} seconds") if parsed_prompt: self.logger.debug( "Tree:\n" + textwrap.indent( re.sub( r"\n$", "", parsed_prompt.pretty() if isinstance(parsed_prompt, lark.Tree) else parsed_prompt, ), " ", ) ) return parsed_prompt class TreeProcessor(lark.visitors.Interpreter): """ A class for interpreting and processing a tree generated by the prompt parser. Args: ppp (PromptPostProcessor): The PromptPostProcessor object. Attributes: add_at (dict): The dictionary to store the content to be added at different positions of the negative prompt. insertion_at (list): The list of insertion points in the negative prompt. detectedWildcards (list): The list of detected invalid wildcards or choices. result (str): The final processed prompt. """ def __init__(self, ppp: "PromptPostProcessor"): super().__init__() self.__ppp = ppp self.AccumulatedShell = namedtuple("AccumulatedShell", ["type", "data"]) self.NegTag = namedtuple("NegTag", ["start", "end", "content", "parameters", "shell"]) self.__shell: list[self.AccumulatedShell] = [] # type: ignore self.__negtags: list[self.NegTag] = [] # type: ignore self.__already_processed: list[str] = [] self.__is_negative = False self.__wildcard_filters = {} self.__seen_wildcards: list[str] = [] self.add_at: dict = {"start": [], "insertion_point": [[] for x in range(10)], "end": []} self.insertion_at: list[tuple[int, int]] = [None for x in range(10)] self.detectedWildcards: list[str] = [] self.result = "" def warn_or_stop(self, message: str, e: Exception = None): if self.__ppp.gen_onwarning == self.__ppp.ONWARNING_CHOICES.stop: raise PPPInterrupt( message, self.__ppp.INVALID_CONTENT_STOP.format(message) if not self.__is_negative else "", self.__ppp.INVALID_CONTENT_STOP.format(message) if self.__is_negative else "", ) from e self.__ppp.logger.warning(message) def start_visit(self, prompt_description: str, parsed_prompt: lark.Tree, is_negative: bool = False) -> str: """ Start the visit process. Args: prompt_description (str): The description of the prompt. parsed_prompt (Tree): The parsed prompt. is_negative (bool): Whether the prompt is negative or not. Returns: str: The processed prompt. """ t1 = time.monotonic_ns() self.__is_negative = is_negative if self.__ppp.debug_level != DEBUG_LEVEL.none: self.__ppp.logger.info(f"Processing {prompt_description}...") self.visit(parsed_prompt) t2 = time.monotonic_ns() if self.__ppp.debug_level != DEBUG_LEVEL.none: self.__ppp.logger.info(f"Process {prompt_description} time: {(t2 - t1) / 1_000_000_000:.3f} seconds") return self.result def __visit( self, node: lark.Tree | lark.Token | list[lark.Tree | lark.Token] | None, restore_state: bool = False, discard_content: bool = False, ) -> str: """ Visit a node in the tree and process it or accumulate its value if it is a Token. Args: node (Tree|Token|list): The node or list of nodes to visit. restore_state (bool): Whether to restore the state after visiting the node. discard_content (bool): Whether to discard the content of the node. Returns: str: The result of the visit. """ backup_result = self.result if restore_state: backup_shell = self.__shell.copy() backup_negtags = self.__negtags.copy() backup_already_processed = self.__already_processed.copy() backup_add_at = self.add_at.copy() backup_insertion_at = self.insertion_at.copy() backup_detectedwildcards = self.detectedWildcards.copy() if node is not None: if isinstance(node, list): for child in node: self.__visit(child) elif isinstance(node, lark.Tree): self.visit(node) elif isinstance(node, lark.Token): self.result += node len_backup = len(backup_result) # if self.result[:len_backup] == backup_result: # this is only necessary if we call parse_prompt with a parser from "start", because it resets the result added_result = self.result[len_backup:] # else: # added_result = self.result if discard_content or restore_state: self.result = backup_result if restore_state: self.__shell = backup_shell self.__negtags = backup_negtags self.__already_processed = backup_already_processed self.add_at = backup_add_at self.insertion_at = backup_insertion_at self.detectedWildcards = backup_detectedwildcards return added_result def __get_original_node_content(self, node: lark.Tree | lark.Token, default=None) -> str: """ Get the original content of a node. Args: node (Tree|Token): The node to get the content from. default: The default value to return if the content is not found. Returns: str: The original content of the node. """ return ( node.meta.content if hasattr(node, "meta") and node.meta is not None and not node.meta.empty else default ) def __get_user_variable_value(self, name: str, evaluate=True, visit=False) -> str: """ Get the value of a user variable. Args: name (str): The name of the user variable. evaluate (bool): Whether to evaluate the variable. visit (bool): Whether to also visit the variable (add to result). Returns: str: The value of the user variable. """ v = self.__ppp.user_variables.get(name, None) if v is not None: visited = False if isinstance(v, lark.Tree): if evaluate: v = self.__visit(v, not visit) visited = visit else: v = self.__get_original_node_content(v, "not evaluated yet") if visit and not visited: self.result += v return v def get_final_user_variable(self, name: str) -> str: return self.__get_user_variable_value(name, True, False) def __set_user_variable_value(self, name: str, value: str): """ Set the value of a user variable. Args: name (str): The name of the user variable. value (str): The value to be set. """ self.__ppp.user_variables[name] = value def __remove_user_variable(self, name: str): """ Remove a user variable. Args: name (str): The name of the user variable. """ if name in self.__ppp.user_variables: del self.__ppp.user_variables[name] def __debug_end(self, construct: str, start_result: str, duration: int, info=None): """ Log the end of a construct processing. Args: construct (str): The name of the construct. start_result (str): The initial result. duration (int): The duration of the processing in ns. info: Additional information to log. """ if self.__ppp.debug_level == DEBUG_LEVEL.full: info = f"({info}) " if info is not None and info != "" else "" output = self.result[len(start_result) :] if output != "": output = f" >> '{output}'" self.__ppp.logger.debug( self.__ppp.format_output( f"TreeProcessor.{construct} {info}({duration / 1_000_000_000:.3f} seconds){output}" ) ) def __eval_basiccondition(self, cond_var: str, cond_comp: str, cond_value: str | list[str]) -> bool: """ Evaluate a condition based on the given variable, comparison, and value. Args: cond_var (str): The variable to be compared. cond_comp (str): The comparison operator. cond_value (str or list[str]): The value to be compared with. Returns: bool: The result of the condition evaluation. """ if cond_var.lower() == "false": var_value = "false" elif cond_var.lower() == "true": var_value = "true" elif cond_var.startswith("_"): # system variable var_value = self.__ppp.system_variables.get(cond_var, None) if var_value is None: var_value = "" self.warn_or_stop(f"Unknown system variable {cond_var}") else: # user variable var_value = self.__get_user_variable_value(cond_var) if var_value is None: var_value = "" self.warn_or_stop(f"Unknown user variable {cond_var}") if isinstance(var_value, str): var_value = var_value.lower() if isinstance(cond_value, list): comp_ops = { "contains": lambda x, y: y in x, "in": lambda x, y: x == y, } else: cond_value = [cond_value] comp_ops = { "eq": lambda x, y: x == y, "ne": lambda x, y: x != y, "gt": lambda x, y: x > y, "lt": lambda x, y: x < y, "ge": lambda x, y: x >= y, "le": lambda x, y: x <= y, "contains": lambda x, y: y in x, "truthy": lambda x, y: bool(x), } if cond_comp not in comp_ops: return False cond_value_adjusted = list( ( c[1:-1].lower() if c.startswith('"') or c.startswith("'") else True if c.lower() == "true" else False if c.lower() == "false" or c == "" else int(c) ) for c in cond_value ) result = False for c in cond_value_adjusted: var_value_adjusted = ( var_value if isinstance(c, str) else ( True if isinstance(c, bool) and var_value != "false" and var_value != "" and var_value is not False else ( False if isinstance(c, bool) and (var_value != "true" or var_value is False) else int(var_value) ) ) ) result = comp_ops[cond_comp](var_value_adjusted, c) if result: break return result def __eval_condition(self, condition: lark.Tree) -> bool: """ Evaluate an if condition based on the given condition tree. Args: condition (Node): The condition tree to be evaluated. Returns: bool: The result of the if condition evaluation. """ # self.__ppp.logger.debug(f"__eval_condition {condition.data}") if condition.data == "operation_and": cond_result = True for c in condition.children: cond_result = cond_result and self.__eval_condition(c) if not cond_result: break elif condition.data == "operation_or": cond_result = False for c in condition.children: cond_result = cond_result or self.__eval_condition(c) if cond_result: break elif condition.data == "operation_not": cond_result = not self.__eval_condition(condition.children[0]) else: # truthy_operand / comparison_simple_value / comparison_list_value # we get the name of the variable cond_var = condition.children[0].value # it should be a Token poscomp = 1 invert = False if poscomp >= len(condition.children): # no condition, just a variable cond_comp = "truthy" cond_value = "true" else: # we get the comparison (with possible not) and the value cond_comp = condition.children[poscomp].value # it should be a Token if cond_comp == "not": invert = not invert poscomp += 1 cond_comp = condition.children[poscomp].value # it should be a Token poscomp += 1 cond_value_node = condition.children[poscomp] cond_value = ( list(v.value for v in cond_value_node.children) if isinstance(cond_value_node, (lark.Tree, list)) else cond_value_node.value if isinstance(cond_value_node, lark.Token) else cond_value_node ) cond_result = self.__eval_basiccondition(cond_var, cond_comp, cond_value) if invert: cond_result = not cond_result return cond_result def promptcomp(self, tree: lark.Tree): """ Process a prompt composition construct in the tree. """ # if self.__ppp.is_comfy_ui(): # self.__ppp.logger.warning("Prompt composition is not supported in ComfyUI.") start_result = self.result t1 = time.monotonic_ns() self.__visit(tree.children[0]) if len(tree.children) > 1: if tree.children[1] is not None: self.result += f":{tree.children[1]}" for i in range(2, len(tree.children), 3): if self.__ppp.cup_ands: self.result = re.sub(r"[, ]+$", "\n" if self.__ppp.cup_ands_eol else " ", self.result) if self.result[-1:].isalnum(): # add space if needed self.result += " " self.result += "AND" added_result = self.__visit(tree.children[i + 1], False, True) if self.__ppp.cup_ands: added_result = re.sub(r"^[, ]+", " ", added_result) if added_result[0:1].isalnum(): # add space if needed added_result = " " + added_result self.result += added_result if tree.children[i + 2] is not None: self.result += f":{tree.children[i+2]}" t2 = time.monotonic_ns() self.__debug_end("promptcomp", start_result, t2 - t1) def scheduled(self, tree: lark.Tree): """ Process a scheduling construct in the tree and add it to the accumulated shell. """ # if self.__ppp.is_comfy_ui(): # self.__ppp.logger.warning("Prompt scheduling is not supported in ComfyUI.") start_result = self.result t1 = time.monotonic_ns() before = tree.children[0] after = tree.children[-2] pos_str = tree.children[-1] pos = float(pos_str) if pos >= 1: pos = int(pos) # self.__shell.append(self.AccumulatedShell("sc", pos)) self.result += "[" if before is not None: if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Shell scheduled before with position {pos}") self.__shell.append(self.AccumulatedShell("scb", pos)) self.__visit(before) self.__shell.pop() if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Shell scheduled after with position {pos}") self.__shell.append(self.AccumulatedShell("sca", pos)) self.result += ":" self.__visit(after) self.__shell.pop() if self.__ppp.cup_emptyconstructs and re.fullmatch(re.escape(start_result) + r"\[:\s*", self.result): self.result = start_result else: self.result += f":{pos_str}]" # self.__shell.pop() t2 = time.monotonic_ns() self.__debug_end("scheduled", start_result, t2 - t1, pos_str) def alternate(self, tree: lark.Tree): """ Process an alternation construct in the tree and add it to the accumulated shell. """ # if self.__ppp.is_comfy_ui(): # self.__ppp.logger.warning("Prompt alternation is not supported in ComfyUI.") start_result = self.result t1 = time.monotonic_ns() # self.__shell.append(self.AccumulatedShell("al", len(tree.children))) self.result += "[" for i, opt in enumerate(tree.children): if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Shell alternate option {i+1}") self.__shell.append(self.AccumulatedShell("alo", {"pos": i + 1, "len": len(tree.children)})) if i > 0: self.result += "|" self.__visit(opt) self.__shell.pop() self.result += "]" if self.__ppp.cup_emptyconstructs and re.fullmatch(re.escape(start_result) + r"\[\s*\]", self.result): self.result = start_result # self.__shell.pop() t2 = time.monotonic_ns() self.__debug_end("alternate", start_result, t2 - t1) def attention(self, tree: lark.Tree): """ Process a attention change construct in the tree and add it to the accumulated shell. """ start_result = self.result t1 = time.monotonic_ns() if len(tree.children) == 2: weight_str = tree.children[-1] if weight_str is not None: weight_kind = 2 # specific weight weight = float(weight_str) else: weight_kind = 1 # increase attention weight = 1.1 weight_str = "1.1" else: weight_kind = 0 # decrease attention weight = 0.9 weight_str = "0.9" if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Shell attention with weight {weight}") current_tree = tree.children[0] if self.__ppp.cup_mergeattention: while isinstance(current_tree, lark.Tree) and current_tree.data == "attention": # we merge the weights if len(current_tree.children) == 2: inner_weight = current_tree.children[-1] if inner_weight is not None: inner_weight = float(inner_weight) else: inner_weight = 1.1 else: inner_weight = 0.9 weight *= inner_weight current_tree = current_tree.children[0] weight = math.floor(weight * 100) / 100 # we round to 2 decimals weight_str = f"{weight:.2f}".rstrip("0").rstrip(".") if weight_str == "0.9": weight_kind = 0 elif weight_str == "1.1": weight_kind = 1 else: weight_kind = 2 if weight_kind == 0 and self.__ppp.is_comfy_ui(): weight_kind = 2 weight_str = "0.9" self.__shell.append(self.AccumulatedShell("at", (weight_kind, weight_str))) if weight_kind == 0: starttag = "[" self.result += starttag self.__visit(current_tree) endtag = "]" elif weight_kind == 1: starttag = "(" self.result += starttag self.__visit(current_tree) endtag = ")" else: starttag = "(" self.result += starttag self.__visit(current_tree) endtag = f":{weight_str})" if self.__ppp.cup_emptyconstructs and re.fullmatch(re.escape(start_result + starttag) + r"\s*", self.result): self.result = start_result else: self.result += endtag self.__shell.pop() t2 = time.monotonic_ns() self.__debug_end("attention", start_result, t2 - t1, weight_str) def commandstn(self, tree: lark.Tree): """ Process a send to negative command in the tree and add it to the list of negative tags. """ start_result = self.result info = None t1 = time.monotonic_ns() if not self.__is_negative: negtagparameters = tree.children[0] if negtagparameters is not None: parameters = negtagparameters.value # should be a token else: parameters = "" content = self.__visit(tree.children[1::], False, True) self.__negtags.append( self.NegTag(len(self.result), len(self.result), content, parameters, self.__shell.copy()) ) info = f"with {parameters or 'no parameters'} : {content}" else: self.warn_or_stop("Ignored negative command in negative prompt") self.__visit(tree.children[1::]) t2 = time.monotonic_ns() self.__debug_end("commandstn", start_result, t2 - t1, info) def commandstni(self, tree: lark.Tree): """ Process a send to negative insertion point command in the tree and add it to the list of negative tags. """ start_result = self.result info = None t1 = time.monotonic_ns() if self.__is_negative: negtagparameters = tree.children[0] if negtagparameters is not None: parameters = negtagparameters.value # should be a token else: parameters = "" self.__negtags.append( self.NegTag(len(self.result), len(self.result), "", parameters, self.__shell.copy()) ) info = f"with {parameters or 'no parameters'}" else: self.warn_or_stop("Ignored negative insertion point command in positive prompt") t2 = time.monotonic_ns() self.__debug_end("commandstni", start_result, t2 - t1, info) def __varset( self, command: str, variable: str, modifiers: lark.Tree | None, content: lark.Tree | None, ): """ Process a generic set command in the tree. """ t1 = time.monotonic_ns() start_result = self.result if variable.startswith("_"): self.warn_or_stop(f"Invalid variable name '{variable}' detected! System variables cannot be set.") return info = variable value_description = self.__get_original_node_content(content, None) value = content modifiers_str: list[str] = [m.value for m in modifiers.children] if modifiers is not None else [] if any(item in modifiers_str for item in ["+", "add"]): info += f" += '{value_description}'" raw_oldvalue = self.__ppp.user_variables.get(variable, None) if raw_oldvalue is None: newvalue = value self.warn_or_stop(f"Unknown variable {variable}") elif isinstance(raw_oldvalue, str): newvalue = lark.Tree( lark.Token("RULE", "varvalue"), [lark.Token("plain", raw_oldvalue), value], # Meta should be {"content": raw_oldvalue + value}, ) else: newvalue = lark.Tree( lark.Token("RULE", "varvalue"), [raw_oldvalue, value], # Meta should be {"content": raw_oldvalue.meta.content + value.meta.content}, ) elif any(item in modifiers_str for item in ["?", "ifundefined"]): info += f" ?= '{value_description}'" raw_oldvalue = self.__ppp.user_variables.get(variable, None) if raw_oldvalue is None: newvalue = value else: info += " (not set)" newvalue = None else: newvalue = value if newvalue is not None: if any(item in modifiers_str for item in ["!", "evaluate"]): newvalue = self.__visit(newvalue, False, True) info += " =! " else: info += " = " self.__set_user_variable_value(variable, newvalue) currentvalue = self.__get_user_variable_value(variable, False) if currentvalue is None: info += "not evaluated yet" else: info += f"'{currentvalue}'" t2 = time.monotonic_ns() self.__debug_end(command, start_result, t2 - t1, info) def variableset(self, tree: lark.Tree): """ Process a DP set variable command in the tree and add it to the dictionary of variables. """ modifiers = tree.children[1] or lark.Tree(lark.Token("RULE", "variablesetmodifiers"), []) immediate = tree.children[2] if immediate is not None: modifiers.children = modifiers.children.copy() modifiers.children.append(immediate) self.__varset("variableset", str(tree.children[0]), modifiers, tree.children[3]) def commandset(self, tree: lark.Tree): """ Process a set command in the tree and add it to the dictionary of variables. """ self.__varset("commandset", str(tree.children[0]), tree.children[1], tree.children[2]) def __varecho(self, command: str, variable: str, default: lark.Tree | None): """ Process a generic echo command in the tree. """ t1 = time.monotonic_ns() start_result = self.result if default is not None: default_value = self.__visit(default, True) # for log value = self.__get_user_variable_value(variable, True, True) if value is None: if default is not None: v = self.__visit(default, False, True) self.__ppp.echoed_variables[variable] = v self.result += v else: self.warn_or_stop(f"Unknown variable {variable}") t2 = time.monotonic_ns() info = variable if default is not None: info += f" with default '{default_value}'" self.__debug_end(command, start_result, t2 - t1, info) def variableuse(self, tree: lark.Tree): """ Process a DP use variable command in the tree. """ self.__varecho("variableuse", str(tree.children[0]), tree.children[1] if len(tree.children) > 1 else None) def commandecho(self, tree: lark.Tree): """ Process an echo command in the tree. """ self.__varecho("commandecho", str(tree.children[0]), tree.children[1] if len(tree.children) > 1 else None) def commandif(self, tree: lark.Tree): """ Process an if command in the tree. """ t1 = time.monotonic_ns() start_result = self.result for i, n in enumerate(tree.children): content = n.children[-1] if len(n.children) == 2: # its not an else # has a condition condition = n.children[0] c = self.__get_original_node_content(condition, f"condition {i}") if self.__eval_condition(condition): self.__visit(content) t2 = time.monotonic_ns() self.__debug_end("commandif", start_result, t2 - t1, c) return else: # its an else self.__visit(content) t2 = time.monotonic_ns() self.__debug_end("commandif", start_result, t2 - t1, "else") return def commandext(self, tree: lark.Tree): """ Process an extranetwork command in the tree. """ t1 = time.monotonic_ns() start_result = self.result extnet = "(ignored)" if not self.__ppp.rem_removeextranetworktags: extnet_type: str = (tree.children[0].children[0] or "") + tree.children[0].children[1] is_mapping = extnet_type.startswith("$") if is_mapping: extnet_type = extnet_type[1:] extnet_id: str = tree.children[1].value if extnet_id.startswith("'") or extnet_id.startswith('"'): extnet_id = extnet_id[1:-1] extnet_id = re.sub(r"\\(.)", r"\1", extnet_id) # so we can escape some special characters parameters: str = "" parameters_defaulted = False if tree.children[2]: parameters = tree.children[2].value elif extnet_type in ("lora", "hypernet"): parameters = "1" parameters_defaulted = True if parameters.startswith("'") or parameters.startswith('"'): parameters = parameters[1:-1] parameters_is_number = bool(re.match(r"^[-+]?\d*\.?\d+$", parameters or "")) condition = tree.children[3] if not condition or self.__eval_condition(condition): extnet_id = f"{extnet_type}:{extnet_id}" triggers = tree.children[4] if len(tree.children) > 4 else None extra_triggers = None compiled_extra_triggers = None if is_mapping: found = self.__ppp.extranetwork_mappings_obj.cached_mappings.get(extnet_id, None) # we assume the conditions do not change inside the prompt found_in_cache = found is not None if found is None: found_mappings: list[PPPENMappingVariant] = [] else_mapping = None if self.__ppp.extranetwork_mappings_obj: enmapping = self.__ppp.extranetwork_mappings_obj.extranetwork_mappings.get( extnet_id, None ) if enmapping: for v in enmapping.variants: if v.condition: try: cnd = self.__ppp.parse_prompt( "condition", v.condition, self.__ppp.parser_condition, True ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing condition '{v.condition}' in extranetwork mapping '{extnet_id}'! : {e.__class__.__name__}", e, ) cnd = None else: cnd = "True" if cnd is not None and (cnd == "True" or self.__eval_condition(cnd)): if v.condition: found_mappings.append(v) else: else_mapping = v if found_mappings: found = found_mappings[ self.__ppp.rng.choice( len(found_mappings), p=[v.weight or 1 for v in found_mappings], ) ] else: found = else_mapping self.__ppp.extranetwork_mappings_obj.cached_mappings[extnet_id] = found if found: if found.name: if not found_in_cache and self.__ppp.debug_level != DEBUG_LEVEL.none: self.__ppp.logger.info( f"Mapping extranetwork '{extnet_id}' to '{extnet_type}:{found.name}'" ) extnet_id = f"{extnet_type}:{found.name}" f_parameters = found.parameters if not f_parameters and extnet_type in ("lora", "hypernet"): f_parameters = "1" found_parameters_is_number = True else: found_parameters_is_number = f_parameters and bool( re.match(r"^[-+]?\d*\.?\d+$", str(f_parameters) or "") ) if found_parameters_is_number and parameters_is_number: parameters = f"{float(f_parameters) * float(parameters):.2f}".rstrip("0").rstrip( "." ) elif f_parameters is not None and parameters_defaulted: parameters = f_parameters elif found.triggers: if not found_in_cache and self.__ppp.debug_level != DEBUG_LEVEL.none: self.__ppp.logger.info(f"Mapping extranetwork '{extnet_id}' to just triggers") extnet_id = None else: if not found_in_cache and self.__ppp.debug_level != DEBUG_LEVEL.none: self.__ppp.logger.info(f"Mapping extranetwork '{extnet_id}' to nothing") extnet_id = None if found.triggers: extra_triggers = ", ".join(found.triggers) try: compiled_extra_triggers = self.__ppp.parse_prompt( "triggers", extra_triggers, self.__ppp.parser_content, True ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing triggers '{extra_triggers}' in extranetwork mapping '{extnet_id}'! : {e.__class__.__name__}", e, ) compiled_extra_triggers = None else: self.warn_or_stop(f"Extranetwork mapping '{extnet_id}' not found!") if extnet_id: extnet = f"<{extnet_id}:{parameters}>" self.result += extnet elif triggers or compiled_extra_triggers: extnet = "(only triggers)" if triggers or compiled_extra_triggers: if extnet_id: if not self.__ppp.cup_extranetworktags: self.result += " " else: self.result += ", " if triggers: self.result += self.__visit(triggers, True, True) if compiled_extra_triggers: if triggers: self.result += ", " self.result += self.__visit(compiled_extra_triggers, True, True) if triggers or compiled_extra_triggers: self.result += ", " t2 = time.monotonic_ns() self.__debug_end("commandext", start_result, t2 - t1, extnet) def extranetworktag(self, tree: lark.Tree): """ Process an extra network construct in the tree. """ t1 = time.monotonic_ns() start_result = self.result if not self.__ppp.rem_removeextranetworktags: self.result += f"<{tree.children[0]}" self.__visit(tree.children[1]) self.result += ">" t2 = time.monotonic_ns() self.__debug_end("extranetworktag", start_result, t2 - t1) def __get_choices_internal_get( self, choice_values: list[dict], filter_specifier: Optional[list[list[str]]] = None, wildcard_key: str = None, ) -> list[dict]: msg_where = f"wildcard '{wildcard_key}'" if wildcard_key else "choices" if filter_specifier is not None: filtered_choice_values = [] for i, c in enumerate(choice_values): passes = False for o in filter_specifier: tmp_pass = True for a in o: if a.isdecimal(): if int(a) != i: tmp_pass = False break elif a.lower() not in c.get("labels", []): tmp_pass = False break if tmp_pass: passes = True break if passes: filtered_choice_values.append(c) if not filtered_choice_values: self.warn_or_stop( f"Wildcard filter specifier '{','.join(['+'.join(y for y in x) for x in filter_specifier])}' found no matches in choices for wildcard '{wildcard_key}'!" ) else: filtered_choice_values = choice_values.copy() expanded_choice_values = [] for i, c in enumerate(filtered_choice_values): if c.get("command", False): content_text = self.__visit(c.get("content", ""), False, True).strip() (cmd, cmd_args) = content_text.split() if cmd == "include": wcs = self.__ppp.wildcard_obj.get_wildcards(cmd_args) if not wcs: self.warn_or_stop(f"Not found included wildcard '{cmd_args}' at {msg_where}!") c_weight = float(c.get("weight", 1.0)) for wc in wcs: if wc.key in self.__seen_wildcards: self.warn_or_stop( f"Circular reference detected including wildcard '{wc.key}' at {msg_where} (chain starts at '{self.__seen_wildcards[0]}')!" ) continue self.__seen_wildcards.append(wc.key) if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Seen wildcard '{wc.key}'") self.__ppp.logger.debug(f"Including choices from wildcard '{wc.key}'") (_, choice_values) = self.__check_wildcard_initialization(wc) if choice_values is not None: ch_values = self.__get_choices_internal_get(choice_values, None, wc.key) for cv in ch_values: expanded_choice_values.append( { **cv, "weight": float(cv.get("weight", 1.0) * c_weight), # we adjust the weight } ) else: self.warn_or_stop(f"Unsupported choice command '{cmd}' at {msg_where}!") else: expanded_choice_values.append(c) return expanded_choice_values def __get_choices_internal_select( self, options: dict | None, choice_values: list[dict], filter_specifier: Optional[list[list[str]]] = None, wildcard_key: str = None, ) -> tuple[str, list[str], str, str]: """ Select choices based on the options. Args: options (dict): The object representing the options construct. choice_values (list[dict]): A list of choice objects. filter_specifier (list[list[str]]): The filter specifier. wildcard_key (str): The wildcard key if it is a wildcard. Returns: tuple: A tuple containing the prefix, selected choices, separator and suffix """ seen_wildcards_len = len(self.__seen_wildcards) if options is None: options = {} sampler: str = options.get("sampler", "~") repeating: bool = options.get("repeating", False) optional: bool = options.get("optional", False) if "count" in options: from_value = options["count"] to_value = from_value else: from_value: int = options.get("from", 1) to_value: int = options.get("to", 1) separator: str = options.get("separator", self.__ppp.wil_choice_separator) msg_where = f"wildcard '{wildcard_key}'" if wildcard_key else "choices" if sampler != "~": self.warn_or_stop(f"Unsupported sampler '{sampler}' at {msg_where} options!") sampler = "~" expanded_choice_values = self.__get_choices_internal_get(choice_values, filter_specifier, wildcard_key) available_choices: list[dict] = [] weights = [] included_choices = 0 excluded_choices = 0 excluded_weights_sum = 0 for i, c in enumerate(expanded_choice_values): c["choice_index"] = i # we index them to later sort the results weight = float(c.get("weight", 1.0)) condition = c.get("if", None) if weight > 0 and (condition is None or self.__eval_condition(condition)): available_choices.append(c) weights.append(weight) included_choices += 1 else: weights.append(-1) excluded_choices += 1 excluded_weights_sum += weight if excluded_choices > 0: # we need to redistribute the excluded weights weights = [weight + excluded_weights_sum / included_choices for weight in weights if weight >= 0] weights = np.array(weights) weights /= weights.sum() # normalize weights if available_choices: if from_value < 0: from_value = 1 elif from_value > len(available_choices): from_value = len(available_choices) if to_value < 1: to_value = 1 elif (to_value > len(available_choices) and not repeating) or from_value > to_value: to_value = len(available_choices) num_choices = ( self.__ppp.rng.integers(from_value, to_value, endpoint=True) if from_value < to_value else from_value ) else: num_choices = 0 if not optional and from_value > 0: self.warn_or_stop(f"Not enough choices found for {msg_where}!") if num_choices < 2: repeating = False if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug( self.__ppp.format_output( f"Selecting {'optional ' if optional else ''}{'repeating ' if repeating else ''}{num_choices} choice" + ("s" if num_choices != 1 else "") + (f" and separating with '{separator}'" if num_choices > 1 else "") ) ) if num_choices > 0: selected_choices: list[dict] = ( list(self.__ppp.rng.choice(available_choices, size=num_choices, p=weights, replace=repeating)) if available_choices else [] ) if self.__ppp.wil_keep_choices_order: selected_choices = sorted(selected_choices, key=lambda x: x["choice_index"]) selected_choices_text = [] prefix: str = ( self.__visit(options.get("prefix", None), False, True) if options.get("prefix", None) is not None else "" ) if prefix != "" and re.match(r"\w", prefix[-1]): prefix += " " for i, c in enumerate(selected_choices): t1 = time.monotonic_ns() choice_content_obj = c.get("content", c.get("text", None)) if isinstance(choice_content_obj, str): choice_content = choice_content_obj else: choice_content = self.__visit(choice_content_obj, False, True) t2 = time.monotonic_ns() if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug( f"Adding choice {i+1} ({(t2-t1) / 1_000_000_000:.3f} seconds):\n" + textwrap.indent(re.sub(r"\n$", "", choice_content), " ") ) selected_choices_text.append(choice_content) suffix: str = ( self.__visit(options.get("suffix", None), False, True) if options.get("suffix", None) is not None else "" ) if suffix != "" and re.match(r"\w", suffix[0]): suffix = " " + suffix # remove comments results = [re.sub(r"\s*#[^\n]*(?:\n|$)", "", r, flags=re.DOTALL) for r in selected_choices_text] else: prefix = "" suffix = "" results = [] if self.__ppp.debug_level == DEBUG_LEVEL.full: list_unseen = [f"'{x}'" for x in self.__seen_wildcards[seen_wildcards_len:]] self.__ppp.logger.debug(f"Unseen wildcards: {', '.join(list_unseen)}") self.__seen_wildcards = self.__seen_wildcards[:seen_wildcards_len] return (prefix, results, separator, suffix) def __get_choices( self, options: dict | None, choice_values: list[dict], filter_specifier: Optional[list[list[str]]] = None, wildcard_key: str = None, ) -> str: r = self.__get_choices_internal_select(options, choice_values, filter_specifier, wildcard_key) if r[1]: return r[0] + r[2].join(r[1]) + r[3] return "" def __convert_choices_options(self, options: Optional[lark.Tree]) -> dict: """ Convert the choices options to a dictionary. Args: options (Tree): The choices options tree. Returns: dict: The converted choices options. """ if options is None: return None options_dict = {} if len(options.children) == 1: options_dict["sampler"] = options.children[0] if options.children[0] is not None else "~" else: options_dict["sampler"] = options.children[0].children[0] if options.children[0] is not None else "~" options_dict["repeating"] = ( "r" in options.children[1].children[0] if options.children[1] is not None else False ) options_dict["optional"] = ( "o" in options.children[1].children[0] if options.children[1] is not None else False ) if len(options.children) == 4: ifrom = 2 ito = 2 isep = 3 else: # 6 ifrom = 2 ito = 3 isep = 4 options_dict["from"] = ( int(options.children[ifrom].children[0]) if options.children[ifrom] is not None else 1 ) options_dict["to"] = int(options.children[ito].children[0]) if options.children[ito] is not None else 1 options_dict["separator"] = ( self.__visit(options.children[isep], False, True) if options.children[isep] is not None else self.__ppp.wil_choice_separator ) return options_dict def __convert_choice(self, choice: lark.Tree) -> dict: """ Convert the choice to a dictionary. Args: choice (Tree): The choice tree. Returns: dict: The converted choice. """ choice_dict = {} choice_dict["command"] = choice.children[0] is not None c_label_obj = choice.children[1] choice_dict["labels"] = ( [x.value.lower() for x in c_label_obj.children[1:-1]] # should be a token if c_label_obj is not None else [] ) choice_dict["weight"] = float(choice.children[2].children[0]) if choice.children[2] is not None else 1.0 choice_dict["if"] = choice.children[3].children[0] if choice.children[3] is not None else None choice_dict["content"] = choice.children[-1] return choice_dict def __check_wildcard_initialization(self, wildcard: PPPWildcard) -> tuple[dict | None, list[dict] | None]: """ Initializes a wildcard if it hasn't been yet. Args: wildcard (PPPWildcard): The wildcard to check. Returns: tuple: A tuple containing the options and choice values of the wildcard. """ choice_values = wildcard.choices options = wildcard.options if choice_values is None: t1 = time.monotonic_ns() choice_values = [] n = 0 # we check the first choice to see if it is actually options if isinstance(wildcard.unprocessed_choices[0], dict): if self.__ppp.wildcard_obj.is_dict_choices_options(wildcard.unprocessed_choices[0]): options = wildcard.unprocessed_choices[0] prefix = options.get("prefix", None) if prefix is not None and isinstance(prefix, str): try: options["prefix"] = self.__ppp.parse_prompt( "choicevalue", prefix, self.__ppp.parser_choicevalue, True ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing choice prefix '{prefix}' in wildcard '{wildcard.key}'! : {e.__class__.__name__}", e, ) suffix = options.get("suffix", None) if suffix is not None and isinstance(suffix, str): try: options["suffix"] = self.__ppp.parse_prompt( "choicevalue", suffix, self.__ppp.parser_choicevalue, True ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing choice suffix '{suffix}' in wildcard '{wildcard.key}'! : {e.__class__.__name__}", e, ) n = 1 else: if wildcard.unprocessed_choices[0].endswith("$$"): try: options = self.__convert_choices_options( self.__ppp.parse_prompt( "as choices options", wildcard.unprocessed_choices[0][:-2].strip(), self.__ppp.parser_choicesoptions, True, ) ) n = 1 except lark.exceptions.UnexpectedInput: options = None if options is None and self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug("Does not have options") wildcard.options = options # we process the choices for cv in wildcard.unprocessed_choices[n:]: if isinstance(cv, dict): if self.__ppp.wildcard_obj.is_dict_choice_options(cv): condition = cv.get("if", None) if condition is not None and isinstance(condition, str): try: cv["if"] = self.__ppp.parse_prompt( "condition", condition, self.__ppp.parser_condition, True ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing condition '{condition}' in wildcard '{wildcard.key}'! : {e.__class__.__name__}", e, ) cv["if"] = None content = cv.get("content", cv.get("text", None)) cv["content"] = content if "text" in cv: del cv["text"] if content is not None and isinstance(content, str): try: cv["content"] = self.__ppp.parse_prompt( "choicevalue", content, self.__ppp.parser_choicevalue, True ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing choice content '{content}' in wildcard '{wildcard.key}'! : {e.__class__.__name__}", e, ) cv["content"] = None if cv["content"] is not None: if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Processed choice {cv}") choice_values.append(cv) else: self.warn_or_stop(f"Invalid choice {cv} in wildcard '{wildcard.key}'!") else: self.warn_or_stop(f"Invalid choice {cv} in wildcard '{wildcard.key}'!") else: try: choice_values.append( self.__convert_choice( self.__ppp.parse_prompt("choice", cv, self.__ppp.parser_choice, True) ) ) except lark.exceptions.UnexpectedInput as e: self.warn_or_stop( f"Error parsing choice '{cv}' in wildcard '{wildcard.key}'! : {e.__class__.__name__}", e ) wildcard.choices = choice_values t2 = time.monotonic_ns() if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug( f"Processed choices for wildcard '{wildcard.key}' ({(t2-t1) / 1_000_000_000:.3f} seconds)" ) return (options, choice_values) def wildcard(self, tree: lark.Tree): """ Process a wildcard construct in the tree. """ t1 = time.monotonic_ns() seen_wildcards_len = len(self.__seen_wildcards) start_result = self.result applied_options = self.__convert_choices_options(tree.children[0]) wildcard_key: str = self.__visit(tree.children[1], False, True) wc = self.__get_original_node_content(tree, f"?__{wildcard_key}__") if self.__ppp.wil_process_wildcards: if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Processing wildcard: {wildcard_key}") selected_wildcards = self.__ppp.wildcard_obj.get_wildcards(wildcard_key) if not selected_wildcards: self.detectedWildcards.append(wc) self.result += wc t2 = time.monotonic_ns() self.__debug_end("wildcard", start_result, t2 - t1, wc) return filter_specifier: list[int | str] = None filter_object = tree.children[2] if filter_object is not None: if ( isinstance(filter_object.children[1], lark.Token) and filter_object.children[1] is not None and "^" in filter_object.children[1] ): filter_wildcard_key = self.__visit(filter_object.children[2], False, True) filter_specifier = self.__wildcard_filters.get(filter_wildcard_key, None) if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug("Filtering choices with inherited filter") else: filter_specifier = [[y.value for y in x.children] for x in filter_object.children[2].children] if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug("Filtering choices") self.__wildcard_filters[wildcard_key] = filter_specifier if ( filter_object.children[1] is not None and "#" in filter_object.children[1] ): # means do not use the filter in this wildcard if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug("Ignoring filter") filter_specifier = None if ( len(selected_wildcards) > 1 and filter_specifier is not None and any(x.isdecimal() for x in filter_specifier) ): self.__ppp.logger.warning( f"Using a globbing wildcard '{wildcard_key}' with positional index filters is not recommended!" ) var_object = tree.children[3] variablename = None variablebackup = None if var_object is not None: variablename = var_object.children[0] # should be a token variablevalue = self.__visit(var_object.children[1], False, True) variablebackup = self.__ppp.user_variables.get(variablename, None) self.__remove_user_variable(variablename) self.__set_user_variable_value(variablename, variablevalue) choice_values_all = [] for wildcard in selected_wildcards: if wildcard is None: self.detectedWildcards.append(wc) self.result += wc t2 = time.monotonic_ns() self.__debug_end("wildcard", start_result, t2 - t1, wc) return if wildcard.key in self.__seen_wildcards: self.warn_or_stop( f"Circular reference detected with wildcard '{self.__seen_wildcards[-1]}' (chain starts at '{self.__seen_wildcards[0]}')!" ) continue self.__seen_wildcards.append(wildcard.key) if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Seen wildcard '{wildcard.key}'") (options, choice_values) = self.__check_wildcard_initialization(wildcard) if options is not None: if applied_options is None: applied_options = options else: if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug(f"Options for wildcard '{wildcard.key}' are ignored!") choice_values_all += choice_values self.result += self.__get_choices(applied_options, choice_values_all, filter_specifier, wildcard_key) if wildcard_key in self.__wildcard_filters: del self.__wildcard_filters[wildcard_key] if variablename is not None: self.__remove_user_variable(variablename) if variablebackup is not None: self.__ppp.user_variables[variablename] = variablebackup elif self.__ppp.wil_ifwildcards != self.__ppp.IFWILDCARDS_CHOICES.remove: self.detectedWildcards.append(wc) self.result += wc if self.__ppp.debug_level == DEBUG_LEVEL.full: list_unseen = [f"'{x}'" for x in self.__seen_wildcards[seen_wildcards_len:]] self.__ppp.logger.debug(f"Unseen wildcards: {', '.join(list_unseen)}") self.__seen_wildcards = self.__seen_wildcards[:seen_wildcards_len] t2 = time.monotonic_ns() self.__debug_end("wildcard", start_result, t2 - t1, f"'{wc}'") def choices(self, tree: lark.Tree): """ Process a choices construct in the tree. """ t1 = time.monotonic_ns() start_result = self.result options = self.__convert_choices_options(tree.children[0]) choice_values = [self.__convert_choice(c) for c in tree.children[1::]] ch = self.__get_original_node_content(tree, "?{...}") if self.__ppp.wil_process_wildcards: if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug("Processing choices:") self.result += self.__get_choices(options, choice_values) elif self.__ppp.wil_ifwildcards != self.__ppp.IFWILDCARDS_CHOICES.remove: self.detectedWildcards.append(ch) self.result += ch t2 = time.monotonic_ns() self.__debug_end("choices", start_result, t2 - t1, f"'{ch}'") def __default__(self, tree): t1 = time.monotonic_ns() start_result = self.result self.__visit(tree.children) t2 = time.monotonic_ns() self.__debug_end(tree.data.value, start_result, t2 - t1) def start(self, tree): self.result = "" t1 = time.monotonic_ns() self.__visit(tree.children) # process the found negative tags for negtag in self.__negtags: if self.__ppp.cup_mergeattention: # join consecutive attention elements for i in range(len(negtag.shell) - 1, 0, -1): if negtag.shell[i].type == "at" and negtag.shell[i - 1].type == "at": new_weight = ( # we limit the new weight to two decimals math.floor(100 * float(negtag.shell[i - 1].data[1]) * float(negtag.shell[i].data[1])) / 100 ) new_weight_str = f"{new_weight:.2f}".rstrip("0").rstrip(".") if new_weight_str == "0.9" and not self.__ppp.is_comfy_ui(): new_kind = 0 elif new_weight_str == "1.1": new_kind = 1 else: new_kind = 2 negtag.shell[i - 1] = self.AccumulatedShell( "at", (new_kind, new_weight_str), ) negtag.shell.pop(i) start = "" end = "" for s in negtag.shell: match s.type: case "at": if s.data[0] == 0: start += "[" end = "]" + end elif s.data[0] == 1: start += "(" end = ")" + end else: start += "(" end = f":{s.data[1]})" + end # case "sc": case "scb": start += "[" end = f"::{s.data}]" + end case "sca": start += "[" end = f":{s.data}]" + end # case "al": case "alo": start += "[" + ("|" * int(s.data["pos"] - 1)) end = ("|" * int(s.data["len"] - s.data["pos"])) + "]" + end content = start + negtag.content + end position = negtag.parameters or "s" if position.startswith("i"): n = int(position[1]) self.insertion_at[n] = [negtag.start, negtag.end] elif len(content) > 0: if content not in self.__already_processed: if self.__ppp.stn_ignore_repeats: self.__already_processed.append(content) if self.__ppp.debug_level == DEBUG_LEVEL.full: self.__ppp.logger.debug( self.__ppp.format_output(f"Adding content at position {position}: {content}") ) if position == "e": self.add_at["end"].append(content) elif position.startswith("p"): n = int(position[1]) self.add_at["insertion_point"][n].append(content) else: # position == "s" or invalid self.add_at["start"].append(content) else: self.__ppp.logger.warning(self.__ppp.format_output(f"Ignoring repeated content: {content}")) t2 = time.monotonic_ns() self.__debug_end("start", "", t2 - t1)